feat: add cli contract and local browser events

This commit is contained in:
QiuSW
2026-07-22 15:19:11 +08:00
parent 82f3c1be0e
commit 26cfa8b921
8 changed files with 210 additions and 6 deletions
+68
View File
@@ -2,8 +2,12 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt"
"io"
"log/slog" "log/slog"
"os" "os"
"strings"
"chub/internal/platform/config" "chub/internal/platform/config"
"chub/internal/platform/logging" "chub/internal/platform/logging"
@@ -18,6 +22,9 @@ import (
const version = "0.1.0-dev" const version = "0.1.0-dev"
func main() { func main() {
if len(os.Args) > 1 {
os.Exit(runCLI(os.Args[1:], os.Stdout, os.Stderr))
}
ctx := context.Background() ctx := context.Background()
logger := logging.New(os.Stderr) logger := logging.New(os.Stderr)
logger.InfoContext(ctx, "chub starting", "version", version) logger.InfoContext(ctx, "chub starting", "version", version)
@@ -25,6 +32,67 @@ func main() {
app.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) { func runWindow(logger *slog.Logger) {
window := new(app.Window) window := new(app.Window)
window.Option(app.Title("Chub 浏览器管理"), app.Size(unit.Dp(1100), unit.Dp(720))) window.Option(app.Title("Chub 浏览器管理"), app.Size(unit.Dp(1100), unit.Dp(720)))
+14
View File
@@ -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())
}
}
+1 -1
View File
@@ -25,7 +25,7 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| T-201 | HTML 原型、Gio 外壳和实例列表/表单 | T-002 | DONE | | T-201 | HTML 原型、Gio 外壳和实例列表/表单 | T-002 | DONE |
| T-202 | 本地配置持久化和实例恢复 | T-101 | 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 | | T-204 | UI 全状态验收与 Windows 打包 smoke | T-201,T-202,T-203 | TODO |
## Backlog ## Backlog
+6 -1
View File
@@ -24,17 +24,20 @@ type BrowserManager interface {
参数数组由 `internal/platform/browser.BuildArgs` 生成;Chrome/Edge executable 由同包 `Discoverer.Resolve` 解析。二者都不接收 shell 命令行字符串。 参数数组由 `internal/platform/browser.BuildArgs` 生成;Chrome/Edge executable 由同包 `Discoverer.Resolve` 解析。二者都不接收 shell 命令行字符串。
## CLI 目标形状 ## CLI 当前合约
```text ```text
chub start --browser chrome --user-data-dir <dir> [--profile-directory <name>] [--url <url>] chub start --browser chrome --user-data-dir <dir> [--profile-directory <name>] [--url <url>]
chub list chub list
chub stop <instance-id> [--force] chub stop <instance-id> [--force]
chub restart <instance-id> chub restart <instance-id>
chub events
``` ```
退出码:0 成功,2 参数错误,3 profile 占用,4 实例不存在,5 身份校验失败,6 系统权限/进程错误。 退出码: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 <instance-id>
| `browser.exited` | 进程退出 | instance_id、exit_code | | `browser.exited` | 进程退出 | instance_id、exit_code |
事件禁止携带密码、Cookie、Token、完整代理认证信息或原始系统堆栈。 事件禁止携带密码、Cookie、Token、完整代理认证信息或原始系统堆栈。
应用内 `application.EventBus` 使用带缓冲的订阅通道,发布不阻塞进程监控;UI 通过订阅刷新状态,取消订阅会关闭对应通道。
+4 -4
View File
@@ -3,11 +3,11 @@
## 快照 ## 快照
- 日期:2026-07-22 - 日期:2026-07-22
- 阶段:Phase 2 产品外壳(T-201、T-202 已完成) - 阶段:Phase 2 产品外壳(T-201 至 T-203 已完成)
- 代码:已建立 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 已完成 - 代码:已建立 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/实时事件由 T-203 接入 - UI:已接入 Gio v0.10.1 双页桌面壳,设置保存异步写入本地配置,启动时恢复设置和已保存实例;CLI `list/events` 已可用,`start/stop/restart` 等待 BrowserManager adapter
- 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目 - 浏览器核心:设计参考来自 `D:\OPC\shop_helm\internal\platform\chrome`,尚未复制或接入本项目
- blocker:无;下一个任务为 T-203(CLI 合约和本地事件推送) - blocker:无;下一个任务为 T-204(UI 全状态验收与 Windows 打包 smoke)
## 当前目录 ## 当前目录
+35
View File
@@ -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 尚未接入,属于后续迭代范围。
+49
View File
@@ -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:
}
}
}
+33
View File
@@ -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")
}
}