diff --git a/vscode/README.md b/vscode/README.md index 5ea0704..ddecb18 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -60,7 +60,7 @@ cd cli && make install # 编译到 bin/spx 并安装到 ~/.local/bin/(这个目录要在 PATH 里) ``` -`spx` 按当前目录 git origin 的 host 选仓库:`github.com` 走 GitHub,凭据取 `gh auth token`;其余走 Gitea,凭据取 tea 配置里 host 匹配的 login,没有时回退环境变量 `GITEA_TOKEN`(tea 把 token 存在系统钥匙串里时就要设它)。 +`spx` 按当前目录 git origin 的 host 选仓库:`github.com` 走 GitHub,凭据取 `gh auth token`;其余走 Gitea,凭据取 tea 配置里 host 匹配的 login,没有就报错,要先 `tea login add` 这个 host;login 里没存 token 时(tea 把 token 存在系统钥匙串里)用环境变量 `GITEA_TOKEN` 补上,`GITEA_TOKEN` 不会发给 tea 没登录过的 host。 | 命令 | 作用 | |---|---| diff --git a/vscode/cli/README.md b/vscode/cli/README.md index f42f889..f56d042 100644 --- a/vscode/cli/README.md +++ b/vscode/cli/README.md @@ -16,9 +16,9 @@ spx 读 `git remote get-url origin`,按 host 决定连哪种仓库(`--host` | origin host | 仓库 | token 来源 | | --- | --- | --- | | `github.com` | GitHub(API `https://api.github.com`) | `gh auth token --hostname github.com`;取不到就先运行 `gh auth login` | -| 其它任何 host | Gitea(API `/api/v1`) | [`tea`](https://gitea.com/gitea/tea) 配置 `~/.config/tea/config.yml` 里 URL 与 origin 同 host 的 login;没有该 login 或 token 为空时回退环境变量 `GITEA_TOKEN` | +| 其它任何 host | Gitea(API `/api/v1`) | [`tea`](https://gitea.com/gitea/tea) 配置 `~/.config/tea/config.yml` 里 URL 与 origin 同 host 的 login;该 login 的 token 为空时用环境变量 `GITEA_TOKEN` 补上;tea 里没有这个 host 的 login 就报错,要先运行 `tea login add` | -tea 用 keyring 存 token(v0.10+ 默认行为)时,可以设 `GITEA_TOKEN` 作为回退: +tea 用 keyring 存 token(v0.10+ 默认行为)时,配置文件里的 login 没有 token,可以设 `GITEA_TOKEN` 补上。`GITEA_TOKEN` 只会发给 tea 里登录过的 host,origin 指向别的 host 时 spx 直接报错,不会把它发出去: ``` export GITEA_TOKEN=... diff --git a/vscode/cli/cmd/spx/main.go b/vscode/cli/cmd/spx/main.go index 2601c7c..f50fcd0 100644 --- a/vscode/cli/cmd/spx/main.go +++ b/vscode/cli/cmd/spx/main.go @@ -111,7 +111,8 @@ func buildRootCmd(open forgeOpener) *cobra.Command { Short: "superpowers-vscode 工作流 CLI(Gitea / GitHub 薄包装)", Long: "spx 为 cc/codex agent 在 superpowers-vscode 工作流里提供确定性的工单 / PR 操作。\n" + "按 git origin 的 host 选仓库:github.com 走 GitHub(token 取自 `gh auth token`),\n" + - "其它 host 走 Gitea(token 取自 ~/.config/tea/config.yml 里同 host 的 login,回退 GITEA_TOKEN)。", + "其它 host 走 Gitea(必须先 `tea login add` 过这个 host,token 取自 ~/.config/tea/config.yml 里同 host 的 login,\n" + + "该 login 没存 token 时才用 GITEA_TOKEN 补上;GITEA_TOKEN 不会发给 tea 没登录过的 host)。", SilenceUsage: true, SilenceErrors: false, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { diff --git a/vscode/cli/internal/forge/open.go b/vscode/cli/internal/forge/open.go index 10834dc..c576715 100644 --- a/vscode/cli/internal/forge/open.go +++ b/vscode/cli/internal/forge/open.go @@ -51,8 +51,9 @@ func ghAuthToken(host string) (string, error) { // Open connects to the repo at `o` on the forge its host names, picking credentials the way that forge expects. // -// GitHub takes the token from `gh auth token`. Gitea takes the token of the tea login for the same host, and its -// URL as the API root; with no such login, or one without a token, it falls back to `GITEA_TOKEN` and the origin's web root. +// GitHub takes the token from `gh auth token`. Gitea needs a [tea.TeaLogin] for the same host and uses its URL as the API +// root and its token; `GITEA_TOKEN` only fills in when that login has no token, so the variable never reaches a host the +// user has not logged in to with tea. func Open(o repo.Origin, env Env) (Forge, error) { if KindForHost(o.Host) == KindGitHub { token, err := env.GitHubToken(o.Host) @@ -69,15 +70,15 @@ func Open(o repo.Origin, env Env) (Forge, error) { if err != nil { return nil, err } - baseURL, token := o.WebURL, "" - if login != nil { - baseURL, token = login.URL, login.Token + if login == nil { + return nil, fmt.Errorf("tea config 里没有 %s 的 login,spx 不会把 Gitea token 发给 tea 没登录过的 host;请先运行 `tea login add` 登录这个 host", o.Host) } + token := login.Token if token == "" { token = env.Getenv("GITEA_TOKEN") } if token == "" { - return nil, fmt.Errorf("没有 %s 的 Gitea token:tea config 里没有该 host 的 login 或其 token 为空,GITEA_TOKEN 也没设置;请运行 `tea login add` 或设置 GITEA_TOKEN", o.Host) + return nil, fmt.Errorf("tea config 里 %s 的 login %q 没有 token,GITEA_TOKEN 也没设置;请重新运行 `tea login add` 或设置 GITEA_TOKEN", o.Host, login.Name) } - return NewGitea(baseURL, token, o.Owner, o.Repo), nil + return NewGitea(login.URL, token, o.Owner, o.Repo), nil } diff --git a/vscode/cli/internal/forge/open_test.go b/vscode/cli/internal/forge/open_test.go index 1eca631..03904d7 100644 --- a/vscode/cli/internal/forge/open_test.go +++ b/vscode/cli/internal/forge/open_test.go @@ -17,6 +17,7 @@ type fakeEnv struct { env map[string]string ghAsked []string teaAsked []string + envAsked []string } func (f *fakeEnv) Env() Env { @@ -29,7 +30,10 @@ func (f *fakeEnv) Env() Env { f.teaAsked = append(f.teaAsked, host) return f.logins[host], nil }, - Getenv: func(key string) string { return f.env[key] }, + Getenv: func(key string) string { + f.envAsked = append(f.envAsked, key) + return f.env[key] + }, } } @@ -133,22 +137,34 @@ func TestOpenGiteaFallsBackToEnvTokenWhenLoginHasNone(t *testing.T) { } } -func TestOpenGiteaWithoutLoginUsesOriginWebURLAndEnvToken(t *testing.T) { - fe := &fakeEnv{env: map[string]string{"GITEA_TOKEN": "env-tok"}} - f, err := Open(giteaOrigin(), fe.Env()) - if err != nil { - t.Fatalf("Open 出错: %v", err) +func TestOpenGiteaWithoutLoginForOriginHostNeverReadsEnvToken(t *testing.T) { + fe := &fakeEnv{ + logins: map[string]*tea.TeaLogin{ + "gitea.example.com": {Name: "work", URL: "https://gitea.example.com", Token: "tea-tok"}, + }, + env: map[string]string{"GITEA_TOKEN": "env-tok"}, } - rf := f.(*restForge) - if rf.apiBase != "https://gitea.example.com/api/v1" || rf.header.Get("Authorization") != "token env-tok" { - t.Fatalf("没有 tea login 时应用 origin 地址 + GITEA_TOKEN: base=%s auth=%q", rf.apiBase, rf.header.Get("Authorization")) + o := repo.Origin{Host: "gitlab.com", WebURL: "https://gitlab.com", Owner: "acme", Repo: "widgets"} + f, err := Open(o, fe.Env()) + if err == nil || f != nil { + t.Fatalf("tea 里没有 origin host 的 login 时应拒绝连接,得到 forge=%v err=%v", f, err) + } + if !strings.Contains(err.Error(), "tea login add") || !strings.Contains(err.Error(), "gitlab.com") { + t.Fatalf("错误应点名 host 并提示先 tea login add,得到 %v", err) + } + if len(fe.envAsked) != 0 { + t.Fatalf("没有同 host 的 login 时不能读 GITEA_TOKEN,免得把它发给别的网站: %v", fe.envAsked) } } -func TestOpenGiteaWithoutAnyTokenFails(t *testing.T) { - fe := &fakeEnv{} +func TestOpenGiteaFailsWhenLoginAndEnvHaveNoToken(t *testing.T) { + fe := &fakeEnv{ + logins: map[string]*tea.TeaLogin{ + "gitea.example.com": {Name: "work", URL: "https://gitea.example.com", Token: ""}, + }, + } _, err := Open(giteaOrigin(), fe.Env()) if err == nil || !strings.Contains(err.Error(), "GITEA_TOKEN") || !strings.Contains(err.Error(), "gitea.example.com") { - t.Fatalf("没有 token 时应报错并点名 host 与 GITEA_TOKEN,得到 %v", err) + t.Fatalf("login 和 GITEA_TOKEN 都没有 token 时应报错并点名 host 与 GITEA_TOKEN,得到 %v", err) } } diff --git a/vscode/cli/internal/tea/config.go b/vscode/cli/internal/tea/config.go index 228c28b..f65a860 100644 --- a/vscode/cli/internal/tea/config.go +++ b/vscode/cli/internal/tea/config.go @@ -59,8 +59,8 @@ func load(path string) (*TeaConfig, error) { // // Hosts are compared by hostname only, ignoring case, scheme, port and trailing slashes, so the host read from a git // SSH remote still finds the login saved with the forge's web URL. When several logins point at `host`, the one marked -// `default` wins, because that is the identity the user picked; otherwise the first. A missing config file is not an -// error either, because the caller can still fall back to `GITEA_TOKEN`. +// `default` wins, because that is the identity the user picked; otherwise the first. A missing config file just means +// there is no login, so it returns `nil` rather than an error. func LoginForHost(path, host string) (*TeaLogin, error) { cfg, err := load(path) if errors.Is(err, fs.ErrNotExist) {