diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44b49609..b708155a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -168,7 +168,9 @@ jobs: GOARCH: ${{ matrix.goarch }} CGO_ENABLED: '0' run: | - VERSION="${GITHUB_REF_NAME#v}" # tag 去 v 前缀;非 tag(dispatch)取 ref 名,仅用于编译冒烟 + # 版本与 app 同源:取自 apps/desktop/package.json(app 运行期版本的唯一真相源), + # 而非独立依赖 git tag——发布前置已校验 tag == 该版本,故二者一致但只有一个来源。 + VERSION="$(node -p "require('$GITHUB_WORKSPACE/apps/desktop/package.json').version")" mkdir -p dist go build -trimpath \ -ldflags "-s -w -X github.com/huhamhire/code-meeseeks/cli/cmd.version=${VERSION}" \ @@ -179,7 +181,7 @@ jobs: shell: bash working-directory: cli/dist run: | - VERSION="${GITHUB_REF_NAME#v}" + VERSION="$(node -p "require('$GITHUB_WORKSPACE/apps/desktop/package.json').version")" # 与 app 同源 BIN="meebox${{ matrix.ext }}" ARCHIVE="meebox-cli-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}" # Bundle LICENSE + README + SKILL.md so the archive is a drop-in agent skill diff --git a/AGENTS.md b/AGENTS.md index 286aa0d0..12f6e5d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,7 @@ npm --prefix apps/desktop run prepare:pragent # 对齐嵌入式 pr-agent 运 - **独立 Go module,不入 npm/Nx**:自带 `cli/go.mod`(纯 Go、无 CGO),非 workspace 成员、不进 Nx——根 `lint/typecheck/test/build` 不覆盖它,CLI 自成一套。 - **本地命令**(在 `cli/`):`go vet ./...` → `go test ./...` → `go build ./...`,改完 CLI 三步过了再收尾。`go.sum` 入库(锁校验和);构建产物(`bin/` / `meebox` 等)已 gitignore(见 `cli/.gitignore`)。 -- **CI 分两条**:PR 门禁 [ci-cli.yml](.github/workflows/ci-cli.yml)(路径过滤 `cli/**`,跑 vet/test/build,与 Node 的 ci.yml 分开);发布产出在 [release.yml](.github/workflows/release.yml) 的 `cli` job(`v*` tag 触发,交叉编译 Windows / macOS / Linux×2,出压缩包挂同一 Release;Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`)。版本经 `-ldflags -X …/cmd.version` 注入、与应用同 tag。 +- **CI 分两条**:PR 门禁 [ci-cli.yml](.github/workflows/ci-cli.yml)(路径过滤 `cli/**`,跑 vet/test/build,与 Node 的 ci.yml 分开);发布产出在 [release.yml](.github/workflows/release.yml) 的 `cli` job(`v*` tag 触发,交叉编译 Windows / macOS / Linux×2,出压缩包挂同一 Release;Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`)。版本**取自 `apps/desktop/package.json`(与 app 同源,即 app 运行期版本的唯一真相源)**经 `-ldflags -X …/cmd.version` 注入——不独立依赖 git tag(发布前置已校验 tag == 该版本)。 - **压缩包即 skill 目录**:CLI 压缩包除二进制外一并打包 `LICENSE` + `cli/README.md` + `cli/SKILL.md`(frontmatter `name: meebox`)——解压投放到 agent 的 skills 目录即得可用 skill(面向 agent 交付的主形态)。 - **写边界**:CLI 做浏览 + **评审写动作**——approve / needswork(远端评审决断)与 comment(发评论),经服务端专用端点(复用 GUI 同源 controller)。仍**不开放**:merge(合并)与 pr-agent 变更类工具(publish 等,`instruct` 只读白名单 describe/review/ask/improve 在 CLI 与服务端双重把关)。新增命令先确认对应 API 端点已存在;放开新写端点须评估远端副作用。CLI 不得绕过 API 直连应用内部。 - **契约同步**:CLI 与服务端唯一耦合是 HTTP/JSON 线协议。当前手写 Go 结构对齐契约,契约增长后转 OpenAPI / Schema 代码生成。默认输出 YAML(人类向、保序)、`--output json` 供机器(亦保序);PR 列表返回精简投影、PR 标识对外为 `id`、PR 关联命令用 `--pr `。连接配置走 flag / 环境变量(`MEEBOX_API_URL` / `MEEBOX_TOKEN`)/ `~/.code-meeseeks/cli.yaml`,**不读 GUI 的 `config.yaml`**(避免越权触达连接层机密);代理遵循标准 `HTTP(S)_PROXY` / `NO_PROXY`。 diff --git a/apps/desktop/src/main/services/api-server/compat.ts b/apps/desktop/src/main/services/api-server/compat.ts new file mode 100644 index 00000000..9679f515 --- /dev/null +++ b/apps/desktop/src/main/services/api-server/compat.ts @@ -0,0 +1,27 @@ +import { lt as semverLt, valid as semverValid } from 'semver'; + +/** + * CLI ↔ server 兼容性门控(见 docs/arch/04-integration/01-service-api.md)。 + * + * CLI 每个请求带上自身版本头,服务端据集中管理的**最低可兼容版本**统一拦截过旧的 CLI——对所有 API 调用 + * 一视同仁,不做按端点的差异化兼容。默认宽松:缺版本头(旧 CLI / 非 CLI 客户端)或版本不可解析(如本地 + * `dev` 构建)均放行,保证既有 CLI 默认可用;仅当版本头**可解析且低于下限**时才门控。 + */ + +/** CLI 在此请求头声明自身版本(Node 会小写化头名)。与 CLI 端手写常量对齐(无代码级共享)。 */ +export const CLI_VERSION_HEADER = 'x-meebox-cli-version'; + +/** + * 服务端可兼容的最低 CLI 版本。破坏性线协议变更时上调此值,即门控掉更旧的 CLI。 + * 取当前 CLI 首发版本为下限(此前无更旧的已发布 CLI),默认不拦截任何在用版本。 + */ +export const MIN_CLI_VERSION = '0.9.0'; + +/** 请求携带的 CLI 版本是否过旧(应拦截)。缺头 / 不可解析 → false(放行)。 */ +export function isClientTooOld(rawHeader: string | string[] | undefined): boolean { + const raw = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader; + if (!raw) return false; + const v = semverValid(raw.trim()); + if (!v) return false; // 不可解析(dev 等)→ 放行 + return semverLt(v, MIN_CLI_VERSION); +} diff --git a/apps/desktop/src/main/services/api-server/server.ts b/apps/desktop/src/main/services/api-server/server.ts index 868d469f..2437611f 100644 --- a/apps/desktop/src/main/services/api-server/server.ts +++ b/apps/desktop/src/main/services/api-server/server.ts @@ -3,6 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import type { BootstrapResult } from '@meebox/config'; import { ERROR_CODES } from '@meebox/shared'; import type { Logger } from 'pino'; +import { CLI_VERSION_HEADER, MIN_CLI_VERSION, isClientTooOld } from './compat.js'; import { HttpError, readJsonBody, sendError, sendOk } from './http.js'; import { matchRoute } from './routes/index.js'; @@ -96,6 +97,13 @@ export class ApiServer { let outcome: { status: number; code?: string }; try { if (!this.authorized(req)) throw new HttpError(401, ERROR_CODES.SV_UNAUTHORIZED); + // 兼容性门控:对所有 API 调用统一拦截过旧的 CLI(缺版本头 / 不可解析 → 放行)。 + if (isClientTooOld(req.headers[CLI_VERSION_HEADER])) { + throw new HttpError(426, ERROR_CODES.SV_CLIENT_TOO_OLD, { + minVersion: MIN_CLI_VERSION, + clientVersion: String(req.headers[CLI_VERSION_HEADER] ?? ''), + }); + } const matched = matchRoute(method, pathname); if (!matched) throw new HttpError(404, ERROR_CODES.SV_NOT_FOUND); const body = method === 'POST' ? await readJsonBody(req) : undefined; diff --git a/cli/README.md b/cli/README.md index 7b894ea3..30230b45 100644 --- a/cli/README.md +++ b/cli/README.md @@ -16,6 +16,18 @@ directory yields a working skill. `SKILL.md` is also embedded in the binary: if only the binary, `meebox skill > SKILL.md` reconstructs the doc (a fallback for rebuilding the skill directory without the archive). +## Install + +macOS / Linux one-liner — downloads the latest release, verifies its SHA-256, installs `meebox` to PATH: + +```bash +curl -fsSL https://raw.githubusercontent.com/huhamhire/code-meeseeks/main/tools/cli/install.sh | bash +``` + +`MEEBOX_VERSION` / `MEEBOX_BIN_DIR` env vars (or `--version` / `--bin-dir` flags) override the release +and install dir. Windows: download the `.zip` from Releases. No separate `SKILL.md` needed — it is +embedded in the binary (`meebox skill` prints it). + ## Build & run This is an independent Go module (`go.mod`), not part of the npm/Nx workspace. diff --git a/cli/cmd/root.go b/cli/cmd/root.go index cbd24145..8996ef2f 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -67,7 +67,7 @@ func resolveClient() (*apiclient.Client, error) { if err != nil { return nil, err } - return apiclient.New(s.APIURL, s.Token), nil + return apiclient.New(s.APIURL, s.Token, version), nil } func outputMode() render.Mode { diff --git a/cli/internal/apiclient/client.go b/cli/internal/apiclient/client.go index ccb417f0..fdf66160 100644 --- a/cli/internal/apiclient/client.go +++ b/cli/internal/apiclient/client.go @@ -15,18 +15,29 @@ import ( "time" ) +// CLIVersionHeader announces the CLI version so the server can gate incompatible +// (too-old) clients. Kept in sync with the server-side constant by convention. +const CLIVersionHeader = "X-Meebox-CLI-Version" + +// CodeClientTooOld is the server error code (ESV0005) returned when this CLI is older +// than the server's minimum supported version. Kept in sync with the shared registry. +const CodeClientTooOld = "ESV0005" + // Client talks to the local API with a bearer token. type Client struct { baseURL string token string + version string http *http.Client } -// New builds a client for the given base URL and bearer token. -func New(baseURL, token string) *Client { +// New builds a client for the given base URL, bearer token, and CLI version (sent as a +// header for server-side compatibility gating; empty is fine and simply omits the header). +func New(baseURL, token, version string) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), token: token, + version: version, http: &http.Client{Timeout: 60 * time.Second}, } } @@ -40,12 +51,34 @@ type APIError struct { } func (e *APIError) Error() string { + if e.Code == CodeClientTooOld { + got := metaString(e.Meta, "clientVersion") + min := metaString(e.Meta, "minVersion") + return fmt.Sprintf( + "incompatible CLI: this meebox (%s) is older than the app requires (minimum %s); "+ + "upgrade the CLI — re-run the install script or download the latest release", + orUnknown(got), orUnknown(min)) + } if e.Code != "" { return fmt.Sprintf("API error %s (HTTP %d)", e.Code, e.Status) } return fmt.Sprintf("API error (HTTP %d)", e.Status) } +func metaString(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func orUnknown(s string) string { + if s == "" { + return "unknown" + } + return s +} + type envelope struct { OK bool `json:"ok"` Data json.RawMessage `json:"data"` @@ -90,6 +123,9 @@ func (c *Client) Post(path string, body any) (json.RawMessage, error) { func (c *Client) do(req *http.Request) (json.RawMessage, error) { req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Accept", "application/json") + if c.version != "" { + req.Header.Set(CLIVersionHeader, c.version) + } resp, err := c.http.Do(req) if err != nil { diff --git a/cli/internal/apiclient/client_test.go b/cli/internal/apiclient/client_test.go new file mode 100644 index 00000000..2aa9980c --- /dev/null +++ b/cli/internal/apiclient/client_test.go @@ -0,0 +1,68 @@ +package apiclient + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSendsCLIVersionHeader(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get(CLIVersionHeader) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true,"data":null}`) + })) + defer srv.Close() + + if _, err := New(srv.URL, "tk", "1.2.3").Get("/api/v1/whoami", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.2.3" { + t.Errorf("%s header = %q, want %q", CLIVersionHeader, got, "1.2.3") + } +} + +func TestNoVersionHeaderWhenEmpty(t *testing.T) { + seen := true + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, seen = r.Header[http.CanonicalHeaderKey(CLIVersionHeader)] + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true,"data":null}`) + })) + defer srv.Close() + + if _, err := New(srv.URL, "tk", "").Get("/api/v1/whoami", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if seen { + t.Errorf("version header should be omitted when version is empty") + } +} + +func TestClientTooOldMessage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUpgradeRequired) // 426 + _, _ = io.WriteString(w, `{"ok":false,"error":{"code":"ESV0005","meta":{"minVersion":"0.9.0","clientVersion":"0.1.0"}}}`) + })) + defer srv.Close() + + _, err := New(srv.URL, "tk", "0.1.0").Get("/api/v1/whoami", nil) + if err == nil { + t.Fatal("expected an error for a too-old client") + } + var ae *APIError + if !errors.As(err, &ae) || ae.Code != CodeClientTooOld { + t.Fatalf("expected APIError with code %s, got %v", CodeClientTooOld, err) + } + msg := err.Error() + // The message must name both versions and steer the user to upgrade. + if !strings.Contains(msg, "0.9.0") || !strings.Contains(msg, "0.1.0") || + !strings.Contains(strings.ToLower(msg), "upgrade") { + t.Errorf("compat message lacks versions/guidance: %q", msg) + } +} diff --git a/docs/arch/04-integration/01-service-api.md b/docs/arch/04-integration/01-service-api.md index dec390a9..088a121f 100644 --- a/docs/arch/04-integration/01-service-api.md +++ b/docs/arch/04-integration/01-service-api.md @@ -58,6 +58,17 @@ GUI 同源 controller(见下「写边界」)。另有 `POST …/refresh` 触 - **路由按领域分模块**:HTTP 路由处理器按业务领域(系统性 / PR / Agent)分置于独立模块,聚合层只做**注册与路径 匹配**、不含业务逻辑——与 [CLI](02-cli.md) 命令树的领域划分对称,新增端点归入对应域、便于定位与扩展。 +### CLI 版本兼容门控 + +服务端对**所有 API 调用**统一做 CLI 版本门控(不按端点差异化): + +- CLI 每个请求带版本头 `X-Meebox-CLI-Version: ` 声明自身版本(取自与 app 同源的版本,见 [CLI](02-cli.md))。 +- 服务端集中管理**最低可兼容 CLI 版本**,请求中间件比对:版本头**可解析且低于下限** → 拦截,返回 + `426 Upgrade Required` + 错误码 `SV_CLIENT_TOO_OLD`(meta 带 `minVersion` / `clientVersion`)。 +- **默认宽松**:缺版本头(旧 CLI / 非 CLI 客户端)或版本不可解析(如本地 `dev` 构建)→ 放行,保证既有 CLI + 默认可用;破坏性线协议变更时上调最低版本即门控掉更旧的 CLI。 +- CLI 收到该错误码即输出明确的「CLI 过旧、请升级」提示(含双方版本),不裸报 HTTP 错误。 + ### 写边界(放开评审动作,拒绝合并与变更类 Agent 工具) - 开放的写操作**限定评审动作**:`POST …/approve`·`…/needswork`(远端评审决断,复用 `prs:setLocalStatus`—— @@ -111,7 +122,7 @@ service: ``` - HTTP 状态码与语义对齐:`400` 校验失败 / `401` 未授权 / `403` 写工具被拒(未开放的写操作)/ `404` 资源不存在 / - `409` 冲突 / `500` 内部错误。 + `409` 冲突 / `426` CLI 版本过旧 / `500` 内部错误。 - 新增 **`SV`(service)错误码领域**(`E`+`SV`+四位,见 [错误码规范](../99-core/04-error-codes.md)): 如 token 无效、写操作被拒、监听未就绪等;与既有 `AG`/`PR`/`NT` 等领域并列。 diff --git a/docs/arch/04-integration/02-cli.md b/docs/arch/04-integration/02-cli.md index cbe4079e..fbf5b5aa 100644 --- a/docs/arch/04-integration/02-cli.md +++ b/docs/arch/04-integration/02-cli.md @@ -126,7 +126,7 @@ agent **不嵌进 `pr`**(避免 `pr agent … --pr` 里 `pr` 重复),与 ` - **输出模式**:`yaml`(默认,人,类 k8s `-o yaml`)/ `json`(机,输出 API `data`);均为响应数据的通用转换。 - **退出码**:`0` 成功 / `1` 通用 / `2` 鉴权 / `3` not found(按需扩展)。 - **二进制与压缩包命名**:`meebox-cli---.`(Windows / macOS 用 `.zip`、Linux 用 `.tar.gz`), - 附 `.sha256` 校验和。`` 与应用版本对齐(同一 `v*` tag)。 + 附 `.sha256` 校验和。`` 取自 `apps/desktop/package.json`(与 app 同源,唯一真相源),发布前置校验其与 `v*` tag 一致。 - **压缩包内容 = 可直接投放的 skill 目录**:除二进制外一并打包 `LICENSE` + `README.md` + `SKILL.md`。解压到 agent 的 skills 目录即得一个可用 skill——`SKILL.md`(frontmatter `name: meebox`)教 agent 用法,紧邻其驱动 的二进制。这是 CLI「面向 agent 交付」的主形态。 @@ -141,7 +141,11 @@ agent **不嵌进 `pr`**(避免 `pr agent … --pr` 里 `pr` 重复),与 ` - **随主工程一起发布**:发布流程的 **Go 构建 job**(`actions/setup-go` + `GOOS`/`GOARCH` 交叉编译矩阵)产出 四平台压缩包(含二进制 + `LICENSE` + `README.md` + `SKILL.md`)+ 校验和,与桌面安装包一并上传到**同一个 GitHub Release**(由现有 `v*` tag 触发,见 [发布流程](../../../AGENTS.md))。 -- 版本号与应用同源(同 tag),确保 CLI 与服务端 API 契约版本可对应。 +- 版本号**取自 `apps/desktop/package.json`(与 app 同源)**,经 `-ldflags` 注入 `cmd.version`——不独立依赖 git tag(发布前置校验 tag 与之一致),确保 CLI 与服务端 API 契约版本可对应。 +- **一键安装脚本(macOS / Linux)**:`tools/cli/install.sh` 经 `curl … | bash` 一条命令完成安装——探测系统 / + 架构 → 取匹配的 Release 压缩包 → 校验 SHA-256 → 解出 `meebox` 装入 `PATH`(默认 `/usr/local/bin`,不可写回退 + `~/.local/bin`;`MEEBOX_VERSION` / `MEEBOX_BIN_DIR` 可覆盖)。刻意**不落地 `SKILL.md`**(已内嵌、`meebox skill` + 可导出)。Windows 不在脚本覆盖内,走手动下载。 ## 扩展与注意事项 @@ -150,6 +154,9 @@ agent **不嵌进 `pr`**(避免 `pr agent … --pr` 里 `pr` 重复),与 ` - **加新命令先加端点**:CLI 不得绕过 API 直连应用内部;能力缺口先在[服务端](01-service-api.md)补端点。 - **不触碰 GUI 机密**:CLI 不读应用主配置 `~/.code-meeseeks/config.yaml`(含各平台访问令牌等连接层机密);服务令牌须经 flag / 环境变量 / `cli.yaml` 显式提供,避免越权触达预期外凭据。 - **契约漂移防护**:初期手写 struct 务必随服务端契约同步更新;契约增长后转 OpenAPI / Schema 代码生成。 +- **版本兼容门控**:CLI 每次请求带 `X-Meebox-CLI-Version` 头声明自身版本(与 app 同源);服务端据集中管理的 + 最低可兼容版本统一门控,CLI 收到 `SV_CLIENT_TOO_OLD`(HTTP 426)即输出「过旧、请升级」提示。见 + [服务端契约](01-service-api.md) 的「CLI 版本兼容门控」。 - **JSON 优先稳定**:`--output json` 是自动化主路径,其字段形状视为对外契约,演进需保持兼容。 - **代理走环境变量**:HTTP client 用 Go `net/http` 默认 transport,天然遵循标准 `HTTP(S)_PROXY` / `NO_PROXY`;loopback(`127.0.0.1` / `localhost`)默认直连不走代理——无需自实现代理逻辑。 diff --git a/docs/guide/06-cli.md b/docs/guide/06-cli.md index 4064cd37..f83704a5 100644 --- a/docs/guide/06-cli.md +++ b/docs/guide/06-cli.md @@ -15,13 +15,21 @@ CLI 依赖应用内的本地 API 服务,默认关闭,需先在 **设置 → ## 2. 获取 CLI -从 [GitHub Release](https://github.com/huhamhire/code-meeseeks/releases) 下载对应平台的压缩包 -(`meebox-cli-<版本>-<系统>-<架构>.zip` / `.tar.gz`),解压后把 `meebox` 可执行文件放到 `PATH` 中。 +**macOS / Linux 一键安装**——自动下载最新版、校验 SHA-256、装到 `PATH`: -覆盖平台:Windows x64、macOS arm64、Linux x64 / arm64。 +```bash +curl -fsSL https://raw.githubusercontent.com/huhamhire/code-meeseeks/main/tools/cli/install.sh | bash +``` + +脚本自动探测系统 / 架构并拉取匹配的 Release 压缩包,把 `meebox` 装到 `/usr/local/bin`(不可写则回退 +`~/.local/bin`)。可用环境变量 `MEEBOX_VERSION`(装指定版本)、`MEEBOX_BIN_DIR`(指定安装目录)调整。 +无需单独安装 `SKILL.md`——它已内嵌进二进制(`meebox skill` 可打印)。 + +**手动下载**(Windows,或不便用脚本时):从 [GitHub Release](https://github.com/huhamhire/code-meeseeks/releases) +下载对应平台压缩包(`meebox-cli-<版本>-<系统>-<架构>.zip` / `.tar.gz`),解压后把 `meebox` 放到 `PATH`。 -压缩包内含 `meebox` 二进制、`LICENSE`、`README.md` 与 `SKILL.md`。**作为 agent skill 使用**:把解压目录直接 -放入 agent 的 skills 目录(如 `~/.claude/skills/meebox/`)即可——`SKILL.md` 会告诉 agent 如何驱动其旁的 `meebox`。 +覆盖平台:Windows x64、macOS arm64、Linux x64 / arm64。压缩包内含 `meebox` 二进制、`LICENSE`、`README.md` +与 `SKILL.md`(作为 agent skill 投放见 [第 6 节](#6-作为-agent-skill-集成))。 ## 3. 连接方式 @@ -149,3 +157,5 @@ meebox pr list --output json | jq '.[].title' (发顶层评论);但**不提供合并(merge)与变更类 Agent 工具(publish 等)**,有此需求请自行对接代码平台。 - **令牌安全**:服务令牌在 GUI 的 `~/.code-meeseeks/config.yaml` 明文存储;若写入 CLI 的 `~/.code-meeseeks/cli.yaml` 同为明文。监听 `0.0.0.0` 暴露到局域网时尤需保密,并及时通过「重新生成」吊销泄露的令牌。 +- **版本兼容**:若 `meebox` 版本低于应用要求的下限,任意命令都会收到「CLI 过旧、请升级」提示(含双方版本); + 按上文「获取 CLI」重装最新版即可。CLI 与应用版本同源发布,正常同步升级不会遇到。 diff --git a/packages/shared/src/error-code.ts b/packages/shared/src/error-code.ts index 603df53f..4496a9c9 100644 --- a/packages/shared/src/error-code.ts +++ b/packages/shared/src/error-code.ts @@ -60,6 +60,8 @@ export const ERROR_CODES = { SV_NOT_FOUND: 'ESV0003', /** 请求体校验失败(→ HTTP 400)。 */ SV_BAD_REQUEST: 'ESV0004', + /** CLI 版本低于服务端可兼容下限(→ HTTP 426;meta.minVersion 最低要求、meta.clientVersion 实际版本)。 */ + SV_CLIENT_TOO_OLD: 'ESV0005', } as const; /** 已登记的错误码字面量联合(抛错时只能用注册过的码,防笔误)。 */ diff --git a/tools/cli/install.sh b/tools/cli/install.sh new file mode 100755 index 00000000..e0add30b --- /dev/null +++ b/tools/cli/install.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# +# One-line installer for the meebox CLI on macOS / Linux. +# +# curl -fsSL https://raw.githubusercontent.com/huhamhire/code-meeseeks/main/tools/cli/install.sh | bash +# +# Detects OS/arch, downloads the matching release archive from GitHub, verifies its +# SHA-256, and installs the `meebox` binary onto PATH. SKILL.md is NOT installed — it is +# embedded in the binary and printable via `meebox skill` (regenerate on demand if needed). +# +# Options (env vars or flags): +# MEEBOX_VERSION / --version install a specific release (e.g. v0.9.0); default: latest +# MEEBOX_BIN_DIR / --bin-dir install target; default: /usr/local/bin if writable, else ~/.local/bin +# +# Windows is not covered by this script — download the .zip from the Releases page. +set -euo pipefail + +REPO="huhamhire/code-meeseeks" +BIN_NAME="meebox" + +VERSION="${MEEBOX_VERSION:-}" +BIN_DIR="${MEEBOX_BIN_DIR:-}" + +err() { printf 'error: %s\n' "$*" >&2; exit 1; } +info() { printf '%s\n' "$*"; } + +usage() { + # Print the header comment block (skip the shebang; stop at the first non-comment line). + awk 'NR==1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0" +} + +while [ $# -gt 0 ]; do + case "$1" in + --version) VERSION="${2:-}"; shift 2 ;; + --version=*) VERSION="${1#*=}"; shift ;; + --bin-dir) BIN_DIR="${2:-}"; shift 2 ;; + --bin-dir=*) BIN_DIR="${1#*=}"; shift ;; + -h|--help) usage; exit 0 ;; + *) err "unknown argument: $1 (see --help)" ;; + esac +done + +need() { command -v "$1" >/dev/null 2>&1 || err "required tool not found: $1"; } +need curl + +# --- detect OS / arch --------------------------------------------------------- +os="$(uname -s)" +case "$os" in + Linux) goos=linux ;; + Darwin) goos=darwin ;; + *) err "unsupported OS: $os (this installer supports Linux and macOS; on Windows download the .zip from Releases)" ;; +esac + +arch="$(uname -m)" +case "$arch" in + x86_64 | amd64) goarch=amd64 ;; + aarch64 | arm64) goarch=arm64 ;; + *) err "unsupported architecture: $arch" ;; +esac + +# Only darwin/arm64 (Apple Silicon) is published; Linux ships amd64 + arm64. +if [ "$goos" = darwin ] && [ "$goarch" != arm64 ]; then + err "no prebuilt macOS binary for $goarch — only Apple Silicon (arm64) is published" +fi + +# macOS archives are .zip, Linux archives are .tar.gz (matches the release workflow). +if [ "$goos" = linux ]; then ext="tar.gz"; else ext="zip"; fi +if [ "$ext" = zip ]; then need unzip; else need tar; fi + +# --- resolve release tag ------------------------------------------------------ +if [ -z "$VERSION" ]; then + info "Resolving latest release..." + tag="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | + grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')" + [ -n "$tag" ] || err "could not resolve the latest release tag" +else + case "$VERSION" in v*) tag="$VERSION" ;; *) tag="v$VERSION" ;; esac +fi +version="${tag#v}" + +archive="${BIN_NAME}-cli-${version}-${goos}-${goarch}.${ext}" +url="https://github.com/${REPO}/releases/download/${tag}/${archive}" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +# --- download + verify -------------------------------------------------------- +info "Downloading ${archive} (${tag})..." +curl -fSL --proto '=https' "$url" -o "$tmp/$archive" || err "download failed: $url" + +if curl -fsSL --proto '=https' "${url}.sha256" -o "$tmp/$archive.sha256" 2>/dev/null; then + info "Verifying checksum..." + want="$(awk '{print $1}' "$tmp/$archive.sha256")" + if command -v sha256sum >/dev/null 2>&1; then + got="$(sha256sum "$tmp/$archive" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + got="$(shasum -a 256 "$tmp/$archive" | awk '{print $1}')" + else + got="" + info "warning: no sha256 tool available; skipping verification" + fi + [ -z "$got" ] || [ "$want" = "$got" ] || err "checksum mismatch for $archive" +else + info "warning: checksum file not found; skipping verification" +fi + +# --- extract ------------------------------------------------------------------ +info "Extracting..." +mkdir -p "$tmp/extract" +if [ "$ext" = zip ]; then + unzip -q -o "$tmp/$archive" -d "$tmp/extract" +else + tar -xzf "$tmp/$archive" -C "$tmp/extract" +fi +[ -f "$tmp/extract/$BIN_NAME" ] || err "binary '$BIN_NAME' not found in $archive" + +# --- install ------------------------------------------------------------------ +if [ -z "$BIN_DIR" ]; then + if [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then + BIN_DIR="/usr/local/bin" + else + BIN_DIR="$HOME/.local/bin" + fi +fi +mkdir -p "$BIN_DIR" +install_path="$BIN_DIR/$BIN_NAME" +mv -f "$tmp/extract/$BIN_NAME" "$install_path" +chmod +x "$install_path" + +info "Installed ${BIN_NAME} ${version} to ${install_path}" +case ":$PATH:" in + *":$BIN_DIR:"*) : ;; + *) info "note: ${BIN_DIR} is not on your PATH — add it, e.g.: export PATH=\"${BIN_DIR}:\$PATH\"" ;; +esac +info "" +info "Next: ${BIN_NAME} login --token # then e.g. ${BIN_NAME} pr list" +info "Tip: ${BIN_NAME} skill # print the embedded agent usage doc"