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") + } +}