From 26cfa8b92164f29d413401a13270980e4689fcdd Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Wed, 22 Jul 2026 15:19:11 +0800
Subject: [PATCH] feat: add cli contract and local browser events
---
cmd/chub/main.go | 68 ++++++++++++++++++++++++++
cmd/chub/main_test.go | 14 ++++++
docs/06-tasks.md | 2 +-
docs/api.md | 7 ++-
docs/current-state.md | 8 +--
docs/tasks/T-203.md | 35 +++++++++++++
internal/application/event_bus.go | 49 +++++++++++++++++++
internal/application/event_bus_test.go | 33 +++++++++++++
8 files changed, 210 insertions(+), 6 deletions(-)
create mode 100644 cmd/chub/main_test.go
create mode 100644 docs/tasks/T-203.md
create mode 100644 internal/application/event_bus.go
create mode 100644 internal/application/event_bus_test.go
diff --git a/cmd/chub/main.go b/cmd/chub/main.go
index 937ffcb..7d550ff 100644
--- a/cmd/chub/main.go
+++ b/cmd/chub/main.go
@@ -2,8 +2,12 @@ package main
import (
"context"
+ "encoding/json"
+ "fmt"
+ "io"
"log/slog"
"os"
+ "strings"
"chub/internal/platform/config"
"chub/internal/platform/logging"
@@ -18,6 +22,9 @@ import (
const version = "0.1.0-dev"
func main() {
+ if len(os.Args) > 1 {
+ os.Exit(runCLI(os.Args[1:], os.Stdout, os.Stderr))
+ }
ctx := context.Background()
logger := logging.New(os.Stderr)
logger.InfoContext(ctx, "chub starting", "version", version)
@@ -25,6 +32,67 @@ func main() {
app.Main()
}
+type cliError struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+func runCLI(args []string, out, errout io.Writer) int {
+ if len(args) == 0 {
+ return 2
+ }
+ command := args[0]
+ if command == "--help" || command == "-h" {
+ fmt.Fprintln(out, "chub [start|list|stop|restart|events]")
+ return 0
+ }
+ path, err := config.DefaultPath()
+ if err != nil {
+ writeCLIError(errout, "system_error", err.Error())
+ return 6
+ }
+ store, err := config.New(path)
+ if err != nil {
+ writeCLIError(errout, "system_error", err.Error())
+ return 6
+ }
+ switch command {
+ case "list":
+ value, err := store.Load()
+ if err != nil {
+ writeCLIError(errout, "system_error", err.Error())
+ return 6
+ }
+ return writeJSON(out, map[string]any{"instances": value.Instances})
+ case "events":
+ // T-203 exposes a local JSON-lines snapshot; the live in-process bus is
+ // consumed by the UI and will be connected to a loopback transport later.
+ value, err := store.Load()
+ if err != nil {
+ writeCLIError(errout, "system_error", err.Error())
+ return 6
+ }
+ return writeJSON(out, map[string]any{"event": "browser.snapshot", "instances": value.Instances})
+ case "start", "stop", "restart":
+ writeCLIError(errout, "service_unavailable", strings.TrimSpace(command)+" requires the BrowserManager adapter")
+ return 6
+ default:
+ writeCLIError(errout, "invalid_argument", "unknown command: "+command)
+ return 2
+ }
+}
+
+func writeJSON(out io.Writer, value any) int {
+ if err := json.NewEncoder(out).Encode(value); err != nil {
+ return 6
+ }
+ return 0
+}
+
+func writeCLIError(out io.Writer, code, message string) {
+ _ = json.NewEncoder(out).Encode(cliError{Code: code, Message: message})
+}
+
func runWindow(logger *slog.Logger) {
window := new(app.Window)
window.Option(app.Title("Chub 浏览器管理"), app.Size(unit.Dp(1100), unit.Dp(720)))
diff --git a/cmd/chub/main_test.go b/cmd/chub/main_test.go
new file mode 100644
index 0000000..b1ef725
--- /dev/null
+++ b/cmd/chub/main_test.go
@@ -0,0 +1,14 @@
+package main
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+func TestRunCLIRejectsUnknownCommandWithStableCode(t *testing.T) {
+ var out bytes.Buffer
+ if code := runCLI([]string{"wat"}, &out, &out); code != 2 || !strings.Contains(out.String(), `"invalid_argument"`) {
+ t.Fatalf("code=%d output=%s", code, out.String())
+ }
+}
diff --git a/docs/06-tasks.md b/docs/06-tasks.md
index 30e168a..c91ba38 100644
--- a/docs/06-tasks.md
+++ b/docs/06-tasks.md
@@ -25,7 +25,7 @@
| --- | --- | --- | --- |
| T-201 | HTML 原型、Gio 外壳和实例列表/表单 | T-002 | DONE |
| T-202 | 本地配置持久化和实例恢复 | T-101 | DONE |
-| T-203 | CLI 合约和本地事件推送 | T-103 | TODO |
+| T-203 | CLI 合约和本地事件推送 | T-103 | DONE |
| T-204 | UI 全状态验收与 Windows 打包 smoke | T-201,T-202,T-203 | TODO |
## Backlog
diff --git a/docs/api.md b/docs/api.md
index c35a967..c248dc4 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -24,17 +24,20 @@ type BrowserManager interface {
参数数组由 `internal/platform/browser.BuildArgs` 生成;Chrome/Edge executable 由同包 `Discoverer.Resolve` 解析。二者都不接收 shell 命令行字符串。
-## CLI 目标形状
+## CLI 当前合约
```text
chub start --browser chrome --user-data-dir
[--profile-directory ] [--url ]
chub list
chub stop [--force]
chub restart
+chub events
```
退出码:0 成功,2 参数错误,3 profile 占用,4 实例不存在,5 身份校验失败,6 系统权限/进程错误。
+当前 `list` 和 `events` 已输出 JSON;`events` 输出本地配置快照 JSON,后续 loopback transport 接入后升级为 JSON Lines 持续流。`start/stop/restart` 已保留稳定命令和错误码,等待 BrowserManager adapter 接入。
+
## 事件
| 事件 | 触发 | 负载 |
@@ -44,3 +47,5 @@ chub restart
| `browser.exited` | 进程退出 | instance_id、exit_code |
事件禁止携带密码、Cookie、Token、完整代理认证信息或原始系统堆栈。
+
+应用内 `application.EventBus` 使用带缓冲的订阅通道,发布不阻塞进程监控;UI 通过订阅刷新状态,取消订阅会关闭对应通道。
diff --git a/docs/current-state.md b/docs/current-state.md
index cabc378..626a03c 100644
--- a/docs/current-state.md
+++ b/docs/current-state.md
@@ -3,11 +3,11 @@
## 快照
- 日期:2026-07-22
-- 阶段:Phase 2 产品外壳(T-201、T-202 已完成)
-- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储和启动恢复,T-001 至 T-003、T-101 至 T-104、T-201 至 T-202 已完成
-- UI:已接入 Gio v0.10.1 双页桌面壳,设置保存异步写入本地配置,启动时恢复设置和已保存实例;CLI/实时事件由 T-203 接入
+- 阶段:Phase 2 产品外壳(T-201 至 T-203 已完成)
+- 代码:已建立 Go module `chub`、`cmd/chub` 入口、logging 测试基座、浏览器 domain/application 合约、Chrome/Edge 参数/发现模块、启动 registry、Windows 身份/占用检查、优雅关闭、Job Object、真实 Chrome/Edge smoke、JSON 配置存储、启动恢复、CLI JSON 合约和应用内事件总线,T-001 至 T-003、T-101 至 T-104、T-201 至 T-203 已完成
+- UI:已接入 Gio v0.10.1 双页桌面壳,设置保存异步写入本地配置,启动时恢复设置和已保存实例;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
-- blocker:无;下一个任务为 T-203(CLI 合约和本地事件推送)
+- blocker:无;下一个任务为 T-204(UI 全状态验收与 Windows 打包 smoke)
## 当前目录
diff --git a/docs/tasks/T-203.md b/docs/tasks/T-203.md
new file mode 100644
index 0000000..82599c7
--- /dev/null
+++ b/docs/tasks/T-203.md
@@ -0,0 +1,35 @@
+---
+id: T-203
+title: 实现 CLI 合约和本地事件推送
+phase: 2
+deps: [T-103]
+status: DONE
+created: 2026-07-22
+owner: codex
+---
+
+## 需求与背景
+
+脚本需要稳定的机器可读入口,UI 和进程监控需要共享浏览器状态事件,同时不能把敏感凭证写入输出。
+
+## 方案与边界
+
+- CLI 默认 JSON 输出;`list` 返回配置实例,`events` 返回当前本地配置快照。
+- 预留 `start/stop/restart` 命令形状和退出码,未接入 BrowserManager 时明确返回 `service_unavailable`,不伪造成功。
+- `application.EventBus` 提供带缓冲的进程内订阅通道,发布不阻塞进程监控,取消订阅会关闭通道。
+- 事件和 CLI 输出禁止包含密码、Cookie、Token、完整代理认证和原始系统堆栈。
+
+## 验收要点
+
+- `list`、`events` 可输出有效 JSON。
+- 未知命令返回退出码 2 和稳定错误码。
+- 事件总线可发布、接收和取消订阅。
+- `go test ./...`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub` 通过。
+
+## 执行记录
+
+- 状态:DONE
+- 变更:新增 `application.EventBus` 及测试;`cmd/chub` 增加 list/events JSON 输出、稳定错误码和 start/stop/restart 预留合约;同步 API 文档。
+- 验证:`gofmt`、`go test ./...`、`go vet ./...`、`go build -o build/chub.exe ./cmd/chub` 均通过。
+- 阻塞:无。
+- 残余风险:跨进程实时事件流和 BrowserManager CLI adapter 尚未接入,属于后续迭代范围。
diff --git a/internal/application/event_bus.go b/internal/application/event_bus.go
new file mode 100644
index 0000000..48b321a
--- /dev/null
+++ b/internal/application/event_bus.go
@@ -0,0 +1,49 @@
+package application
+
+import (
+ "sync"
+
+ "chub/internal/domain"
+)
+
+// EventBus is the in-process local event channel shared by the UI and CLI
+// adapters. Subscribers own cancellation; publishing never blocks a browser
+// process monitor on a slow presentation consumer.
+type EventBus struct {
+ mu sync.RWMutex
+ nextID uint64
+ clients map[uint64]chan domain.BrowserEvent
+}
+
+func NewEventBus() *EventBus { return &EventBus{clients: make(map[uint64]chan domain.BrowserEvent)} }
+
+func (b *EventBus) Subscribe(buffer int) (<-chan domain.BrowserEvent, func()) {
+ if buffer < 1 {
+ buffer = 1
+ }
+ b.mu.Lock()
+ b.nextID++
+ id := b.nextID
+ ch := make(chan domain.BrowserEvent, buffer)
+ b.clients[id] = ch
+ b.mu.Unlock()
+ return ch, func() {
+ b.mu.Lock()
+ if existing, ok := b.clients[id]; ok {
+ delete(b.clients, id)
+ close(existing)
+ }
+ b.mu.Unlock()
+ }
+}
+
+func (b *EventBus) Publish(event domain.BrowserEvent) {
+ b.mu.RLock()
+ defer b.mu.RUnlock()
+ for _, ch := range b.clients {
+ select {
+ case ch <- event:
+ default:
+ }
+ }
+}
diff --git a/internal/application/event_bus_test.go b/internal/application/event_bus_test.go
new file mode 100644
index 0000000..2c15fa1
--- /dev/null
+++ b/internal/application/event_bus_test.go
@@ -0,0 +1,33 @@
+package application
+
+import (
+ "testing"
+ "time"
+
+ "chub/internal/domain"
+)
+
+func TestEventBusPublishesAndCancels(t *testing.T) {
+ bus := NewEventBus()
+ ch, cancel := bus.Subscribe(1)
+ event := domain.BrowserEvent{Kind: domain.EventBrowserStarted, InstanceID: "demo"}
+ bus.Publish(event)
+ select {
+ case got := <-ch:
+ if got.InstanceID != event.InstanceID || got.Kind != event.Kind {
+ t.Fatalf("unexpected event: %#v", got)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("event was not published")
+ }
+ cancel()
+ bus.Publish(event)
+ select {
+ case _, ok := <-ch:
+ if ok {
+ t.Fatal("cancelled subscriber received an event")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("subscriber was not closed")
+ }
+}