feat(auth): 新增 auth token 子命令与 auth status --show-token(对标 gh auth token)

This commit is contained in:
Taoyouce 2026-07-07 15:33:39 +00:00
parent c09645da62
commit b8e92895f4
6 changed files with 158 additions and 2 deletions

View File

@ -847,6 +847,13 @@ gitlink-cli repo +list # Ready to use
gitlink-cli auth status # Shows "✓ Logged in via GITLINK_TOKEN environment variable"
```
To reuse the active token in scripts (e.g. raw `curl` calls against endpoints the CLI does not wrap yet):
```bash
curl -H "Authorization: Bearer $(gitlink-cli auth token)" https://www.gitlink.org.cn/api/v1/...
gitlink-cli auth status --show-token # Inspect the raw token (hidden by default)
```
Priority: `GITLINK_TOKEN` env var > keyring/file stored token. When the env var is not set, the original interactive login flow works as before.
### Q: What if npm installs successfully but `gitlink-cli` reports a missing binary?

View File

@ -719,6 +719,13 @@ gitlink-cli repo +list # 直接可用
gitlink-cli auth status # 显示 "✓ Logged in via GITLINK_TOKEN environment variable"
```
脚本中复用当前生效的 token例如直接 `curl` CLI 尚未封装的端点):
```bash
curl -H "Authorization: Bearer $(gitlink-cli auth token)" https://www.gitlink.org.cn/api/v1/...
gitlink-cli auth status --show-token # 查看原始 token默认隐藏
```
Token 优先级:`GITLINK_TOKEN` 环境变量 > keyring/文件存储的 token。不设置环境变量时完全兼容原有交互式登录。
### Q: npm 安装成功但 `gitlink-cli` 提示缺少二进制怎么办?

View File

@ -34,6 +34,7 @@ func NewAuthCmd(translators ...*i18n.Translator) *cobra.Command {
cmd.AddCommand(newLoginCmd(tr))
cmd.AddCommand(newLogoutCmd(tr))
cmd.AddCommand(newStatusCmd(tr))
cmd.AddCommand(newTokenCmd(tr))
return cmd
}
@ -132,7 +133,9 @@ func newLogoutCmd(tr *i18n.Translator) *cobra.Command {
}
func newStatusCmd(tr *i18n.Translator) *cobra.Command {
return &cobra.Command{
var showToken bool
cmd := &cobra.Command{
Use: "status",
Short: tr.T("cmd.auth.status.short"),
RunE: func(cmd *cobra.Command, args []string) error {
@ -142,9 +145,19 @@ func newStatusCmd(tr *i18n.Translator) *cobra.Command {
if _, err := fmt.Fprintln(out, tr.Tf("success.auth.logged_in_via_env", i18n.Args{"env": envTokenVar})); err != nil {
return err
}
if showToken {
if _, err := fmt.Fprintln(out, tr.Tf("output.auth.token_value", i18n.Args{"token": envToken})); err != nil {
return err
}
}
}
token, err := loadToken()
if err == nil && token != "" && showToken {
if _, err := fmt.Fprintln(out, tr.Tf("output.auth.token_value", i18n.Args{"token": token})); err != nil {
return err
}
}
if err != nil || token == "" {
if os.Getenv(envTokenVar) == "" {
if _, err := fmt.Fprintln(out, tr.T("warning.auth.not_logged_in")); err != nil {
@ -180,4 +193,29 @@ func newStatusCmd(tr *i18n.Translator) *cobra.Command {
return err
},
}
cmd.Flags().BoolVar(&showToken, "show-token", false, tr.T("flag.auth.show_token"))
return cmd
}
// newTokenCmd prints the active token to stdout for scripting, mirroring
// `gh auth token`. Resolution order matches API calls: GITLINK_TOKEN env
// var first, then the stored keyring/file token.
func newTokenCmd(tr *i18n.Translator) *cobra.Command {
return &cobra.Command{
Use: "token",
Short: tr.T("cmd.auth.token.short"),
Long: tr.T("cmd.auth.token.long"),
RunE: func(cmd *cobra.Command, args []string) error {
token := os.Getenv(envTokenVar)
if token == "" {
stored, err := loadToken()
if err != nil || stored == "" {
return errors.New(tr.T("error.auth.no_token"))
}
token = stored
}
_, err := fmt.Fprintln(cmd.OutOrStdout(), token)
return err
},
}
}

View File

@ -1,9 +1,11 @@
package auth
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
@ -35,7 +37,7 @@ func TestNewAuthCmd(t *testing.T) {
}
expectedSubs := map[string]bool{
"login": false, "logout": false, "status": false,
"login": false, "logout": false, "status": false, "token": false,
}
for _, sub := range cmd.Commands() {
if _, ok := expectedSubs[sub.Use]; !ok {
@ -130,6 +132,98 @@ func TestStatusCmdStoredTokenButLoadFails(t *testing.T) {
}
}
func TestTokenCmdEnvToken(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "env-token-123")
_ = internalAuth.DeleteToken()
cmd := findSub(NewAuthCmd(), "token")
if cmd == nil {
t.Fatal("token subcommand not found")
}
var buf bytes.Buffer
cmd.SetOut(&buf)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("token error: %v", err)
}
if got := strings.TrimSpace(buf.String()); got != "env-token-123" {
t.Fatalf("token output = %q, want env-token-123", got)
}
}
func TestTokenCmdStoredToken(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
dir := tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
os.MkdirAll(dir, 0700)
os.WriteFile(filepath.Join(dir, "credentials"), []byte("stored-token-456"), 0600)
cmd := findSub(NewAuthCmd(), "token")
var buf bytes.Buffer
cmd.SetOut(&buf)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("token error: %v", err)
}
if got := strings.TrimSpace(buf.String()); got != "stored-token-456" {
t.Fatalf("token output = %q, want stored-token-456", got)
}
}
func TestTokenCmdNoToken(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
_ = internalAuth.DeleteToken()
cmd := findSub(NewAuthCmd(), "token")
var buf bytes.Buffer
cmd.SetOut(&buf)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("expected error when no token is available")
}
}
func TestStatusCmdShowToken(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
dir := tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
os.MkdirAll(dir, 0700)
os.WriteFile(filepath.Join(dir, "credentials"), []byte("stored-token-789"), 0600)
cmd := findSub(NewAuthCmd(), "status")
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.Flags().Set("show-token", "true")
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("status error: %v", err)
}
if !strings.Contains(buf.String(), "stored-token-789") {
t.Fatalf("status --show-token output missing token: %q", buf.String())
}
}
func TestStatusCmdHidesTokenByDefault(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
dir := tempConfigDir(t)
t.Setenv("GITLINK_TOKEN", "")
os.MkdirAll(dir, 0700)
os.WriteFile(filepath.Join(dir, "credentials"), []byte("stored-token-789"), 0600)
cmd := findSub(NewAuthCmd(), "status")
var buf bytes.Buffer
cmd.SetOut(&buf)
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("status error: %v", err)
}
if strings.Contains(buf.String(), "stored-token-789") {
t.Fatalf("status leaked token without --show-token: %q", buf.String())
}
}
func TestLogoutCmdNoStoredToken(t *testing.T) {
keyring.MockInitWithError(errors.New("keychain unavailable"))
tempConfigDir(t)

View File

@ -5,6 +5,8 @@
"cmd.auth.logout.short": "Logout from GitLink",
"cmd.auth.short": "Authentication commands",
"cmd.auth.status.short": "Show authentication status",
"cmd.auth.token.long": "Print the token that gitlink-cli would use for API calls, for use in scripts (e.g. `curl -H \"Authorization: Bearer $(gitlink-cli auth token)\"`). Resolution order: GITLINK_TOKEN environment variable, then the stored token.",
"cmd.auth.token.short": "Print the active authentication token",
"cmd.branch.create.short": "Create a branch",
"cmd.branch.delete.short": "Delete a branch",
"cmd.branch.list.short": "List branches",
@ -107,6 +109,7 @@
"cmd.webhook.view.short": "View webhook details",
"error.auth.delete_token_failed": "failed to delete token: {message}",
"error.auth.login_failed": "login failed: {message}",
"error.auth.no_token": "no token found: run `gitlink-cli auth login` or set GITLINK_TOKEN",
"error.auth.store_token_failed": "failed to store token: {message}",
"error.auth.token_empty": "token cannot be empty",
"error.config.save_failed": "failed to save config: {message}",
@ -123,6 +126,7 @@
"flag.api.body_stdin": "Read request body JSON from stdin",
"flag.api.header": "Additional headers (key:value)",
"flag.api.query": "Query parameters (key=val&key2=val2)",
"flag.auth.show_token": "Display the raw token in the status output",
"flag.auth.token": "Login by pasting an existing token",
"flag.branch.from": "Source branch or commit",
"flag.branch.name": "Branch name",
@ -231,6 +235,7 @@
"flag.webhook.url": "Webhook target URL",
"output.auth.env_hint": " Or set {env} environment variable",
"output.auth.login_hint": " Run: gitlink-cli auth login",
"output.auth.token_value": " Token: {token}",
"output.config.file": "Config file: {path}",
"output.config.not_set": "(not set)",
"output.doctor.api_auth.config_skipped": "API authentication check skipped because the configuration file is invalid.",

View File

@ -5,6 +5,8 @@
"cmd.auth.logout.short": "退出 GitLink 登录",
"cmd.auth.short": "认证命令",
"cmd.auth.status.short": "显示认证状态",
"cmd.auth.token.long": "输出 gitlink-cli 调用 API 时实际使用的 token便于脚本使用如 `curl -H \"Authorization: Bearer $(gitlink-cli auth token)\"`。解析顺序GITLINK_TOKEN 环境变量优先,其次是已保存的 token。",
"cmd.auth.token.short": "输出当前生效的认证 token",
"cmd.branch.create.short": "创建分支",
"cmd.branch.delete.short": "删除分支",
"cmd.branch.list.short": "列出分支",
@ -107,6 +109,7 @@
"cmd.webhook.view.short": "查看 Webhook 详情",
"error.auth.delete_token_failed": "删除 Token 失败:{message}",
"error.auth.login_failed": "登录失败:{message}",
"error.auth.no_token": "未找到 token请运行 `gitlink-cli auth login` 或设置 GITLINK_TOKEN",
"error.auth.store_token_failed": "保存 Token 失败:{message}",
"error.auth.token_empty": "Token 不能为空",
"error.config.save_failed": "保存配置失败:{message}",
@ -123,6 +126,7 @@
"flag.api.body_stdin": "从标准输入读取 JSON 请求体",
"flag.api.header": "附加请求头key:value",
"flag.api.query": "查询参数key=val&key2=val2",
"flag.auth.show_token": "在状态输出中显示原始 token",
"flag.auth.token": "通过粘贴已有 Token 登录",
"flag.branch.from": "源分支或 Commit",
"flag.branch.name": "分支名称",
@ -231,6 +235,7 @@
"flag.webhook.url": "Webhook 目标 URL",
"output.auth.env_hint": " 或设置 {env} 环境变量",
"output.auth.login_hint": " 运行gitlink-cli auth login",
"output.auth.token_value": " Token: {token}",
"output.config.file": "配置文件:{path}",
"output.config.not_set": "(未设置)",
"output.doctor.api_auth.config_skipped": "配置文件无效,已跳过 API 认证检查。",