feat: add cli contract and local browser events
This commit is contained in:
@@ -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)))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+6
-1
@@ -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 <dir> [--profile-directory <name>] [--url <url>]
|
||||
chub list
|
||||
chub stop <instance-id> [--force]
|
||||
chub restart <instance-id>
|
||||
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 <instance-id>
|
||||
| `browser.exited` | 进程退出 | instance_id、exit_code |
|
||||
|
||||
事件禁止携带密码、Cookie、Token、完整代理认证信息或原始系统堆栈。
|
||||
|
||||
应用内 `application.EventBus` 使用带缓冲的订阅通道,发布不阻塞进程监控;UI 通过订阅刷新状态,取消订阅会关闭对应通道。
|
||||
|
||||
@@ -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)
|
||||
|
||||
## 当前目录
|
||||
|
||||
|
||||
@@ -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 尚未接入,属于后续迭代范围。
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user