From 37e736c4d8d20bf23723dcdfcf67a5ef1ebc872c Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Thu, 28 May 2026 11:08:53 +0800 Subject: [PATCH] feat(user): add SSH key shortcuts --- README.md | 20 ++- README.zh-CN.md | 21 +++- doc/changes/user-key-shortcuts.md | 28 +++++ internal/i18n/locales/en-US.json | 7 ++ internal/i18n/locales/zh-CN.json | 7 ++ shortcuts/user/user.go | 133 ++++++++++++++++++++ shortcuts/user/user_test.go | 199 +++++++++++++++++++++++++++++- skills/README.md | 2 +- skills/gitlink-user/SKILL.md | 14 +++ 9 files changed, 424 insertions(+), 7 deletions(-) create mode 100644 doc/changes/user-key-shortcuts.md diff --git a/README.md b/README.md index e5e4318..2843a36 100644 --- a/README.md +++ b/README.md @@ -551,6 +551,24 @@ gitlink-cli profile +activity gitlink-cli profile +contribution --user zhangsan --year 2025 ``` +### User Account + +```bash +# Show current authenticated user +gitlink-cli user +me + +# List SSH public keys +gitlink-cli user +keys + +# Add an SSH public key from inline content or a file +gitlink-cli user +add-key --title laptop --key "ssh-ed25519 AAAA..." +gitlink-cli user +add-key --title laptop --from ~/.ssh/id_ed25519.pub +gitlink-cli user +add-key --from ~/.ssh/id_rsa.pub + +# Delete an SSH public key +gitlink-cli user +delete-key --id 123 +``` + ### Workflow Agent Commands `workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports: @@ -747,7 +765,7 @@ See [skills/README.md](./skills/README.md) for details. | `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, etc.) | | `gitlink-search` | Search (repositories, users, etc.) | | `gitlink-org` | Organization management (members, teams, etc.) | -| `gitlink-user` | User management (profile info, etc.) | +| `gitlink-user` | User management (profile info, SSH keys, etc.) | | `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) | | `gitlink-workflow` | AI-powered workflows (issue triage, PR review, release notes, etc.) | | `gitlink-health` | Project health analysis (PR/Issue metrics aggregation, health reports) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..9b773ee 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -550,6 +550,25 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid --ye ``` > 注意:`dataset +list`(平台数据集查询)已在生产 gitlink.org.cn 验证可用。按仓库的 `+view`/`+create`/`+update` 遵循已发布的 OpenAPI 契约,但生产环境尚未部署(当前返回 404),待平台上线后即可生效。 + +### 用户账号 + +```bash +# 查看当前登录用户 +gitlink-cli user +me + +# 列出 SSH 公钥 +gitlink-cli user +keys + +# 通过内容或文件添加 SSH 公钥 +gitlink-cli user +add-key --title laptop --key "ssh-ed25519 AAAA..." +gitlink-cli user +add-key --title laptop --from ~/.ssh/id_ed25519.pub +gitlink-cli user +add-key --from ~/.ssh/id_rsa.pub + +# 删除 SSH 公钥 +gitlink-cli user +delete-key --id 123 +``` + ### Raw API Shortcuts 未覆盖的接口可通过 Raw API 直接调用: @@ -620,7 +639,7 @@ git push gitlink | `gitlink-ci` | CI/CD 操作(构建、日志等) | | `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) | | `gitlink-search` | 搜索功能(仓库、用户等) | -| `gitlink-user` | 用户管理(个人信息等) | +| `gitlink-user` | 用户管理(个人信息、SSH 公钥等) | | `gitlink-pm` | 项目管理(Sprint、看板、周报等) | | `gitlink-workflow` | AI 自动化工作流(Issue 分类、PR Review、Release Notes 等) | diff --git a/doc/changes/user-key-shortcuts.md b/doc/changes/user-key-shortcuts.md new file mode 100644 index 0000000..98c719a --- /dev/null +++ b/doc/changes/user-key-shortcuts.md @@ -0,0 +1,28 @@ +# User SSH Key Shortcuts + +## Summary + +Adds account-level SSH public key management shortcuts under the existing `user` command group. + +## Commands + +```bash +gitlink-cli user +keys +gitlink-cli user +add-key --title laptop --key "ssh-ed25519 AAAA..." +gitlink-cli user +add-key --title laptop --from ~/.ssh/id_ed25519.pub +gitlink-cli user +add-key --from ~/.ssh/id_rsa.pub +gitlink-cli user +delete-key --id 123 +``` + +## Behavior + +- `user +keys` calls `GET /public_keys` with `--page` and `--limit`. +- `user +add-key` calls `POST /public_keys` with `title` and `key`. +- `user +delete-key` calls `DELETE /public_keys/{id}`. +- `user +add-key` accepts either inline key content or a public key file path. +- `user +add-key --from` can infer the default title from the public key filename. +- Key content and key IDs are validated before a write/delete request is sent. + +## Tests + +- Unit tests cover list pagination, inline key creation, file-based key creation, invalid key sources, delete path construction, and invalid key IDs. diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..8023335 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -93,7 +93,10 @@ "cmd.search.repos.short": "Search repositories", "cmd.search.short": "Search operations", "cmd.search.users.short": "Search users", + "cmd.user.add_key.short": "Add an SSH public key to the current user", + "cmd.user.delete_key.short": "Delete one of the current user's SSH public keys", "cmd.user.info.short": "Show user profile", + "cmd.user.keys.short": "List current user's SSH public keys", "cmd.user.me.short": "Show current authenticated user", "cmd.user.short": "User operations", "cmd.version.short": "Print version information", @@ -218,6 +221,10 @@ "flag.sort_by": "Sort field", "flag.sort_direction": "Sort direction: asc, desc", "flag.user": "User login (default: current user)", + "flag.user.key.content": "SSH public key content", + "flag.user.key.from": "Read SSH public key content from a file", + "flag.user.key.id": "SSH key ID", + "flag.user.key.title": "SSH key title. Defaults to the filename when --from is used.", "flag.user.login": "User login name", "flag.webhook.active": "Whether the webhook is active: true or false", "flag.webhook.branch_filter": "Branch glob filter for push/create/delete events", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..aa2fbea 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -93,7 +93,10 @@ "cmd.search.repos.short": "搜索仓库", "cmd.search.short": "搜索操作", "cmd.search.users.short": "搜索用户", + "cmd.user.add_key.short": "为当前用户添加 SSH 公钥", + "cmd.user.delete_key.short": "删除当前用户的 SSH 公钥", "cmd.user.info.short": "显示用户资料", + "cmd.user.keys.short": "列出当前用户的 SSH 公钥", "cmd.user.me.short": "显示当前认证用户", "cmd.user.short": "用户操作", "cmd.version.short": "打印版本信息", @@ -218,6 +221,10 @@ "flag.sort_by": "排序字段", "flag.sort_direction": "排序方向:asc、desc", "flag.user": "用户登录名(默认:当前用户)", + "flag.user.key.content": "SSH 公钥内容", + "flag.user.key.from": "从文件读取 SSH 公钥内容", + "flag.user.key.id": "SSH 公钥 ID", + "flag.user.key.title": "SSH 公钥标题。使用 --from 时默认取文件名。", "flag.user.login": "用户登录名", "flag.webhook.active": "Webhook 是否启用:true 或 false", "flag.webhook.branch_filter": "用于 push/create/delete 事件的分支 glob 筛选", diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index 563db88..db2a02e 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -2,6 +2,11 @@ package user import ( "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -39,6 +44,77 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "keys", + Description: tr.T("cmd.user.keys.short"), + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", publicKeysPath(), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "add-key", + Description: tr.T("cmd.user.add_key.short"), + Flags: []common.Flag{ + {Name: "title", Short: "t", Usage: tr.T("flag.user.key.title")}, + {Name: "key", Short: "k", Usage: tr.T("flag.user.key.content")}, + {Name: "from", Short: "f", Usage: tr.T("flag.user.key.from")}, + }, + Run: func(ctx *common.RuntimeContext) error { + key, err := publicKeyContent(ctx.Arg("key"), ctx.Arg("from")) + if err != nil { + return err + } + title, err := publicKeyTitle(ctx.Arg("title"), ctx.Arg("from")) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", publicKeysPath(), map[string]interface{}{ + "title": title, + "key": key, + }) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete-key", + Description: tr.T("cmd.user.delete_key.short"), + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: tr.T("flag.user.key.id"), Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + id = strings.TrimSpace(id) + if id == "" { + return fmt.Errorf("required flag --id is missing") + } + keyID, err := parsePublicKeyID(id) + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/%d", publicKeysPath(), keyID), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } @@ -48,3 +124,60 @@ func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { } return i18n.Default() } + +func parsePublicKeyID(raw string) (int, error) { + id, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || id <= 0 { + return 0, fmt.Errorf("SSH key ID must be a positive integer") + } + return id, nil +} + +func publicKeysPath() string { + return "/public_keys" +} + +func publicKeyTitle(title, from string) (string, error) { + title = strings.TrimSpace(title) + if title != "" { + return title, nil + } + from = strings.TrimSpace(from) + if from != "" { + if base := filepath.Base(from); base != "." && base != string(filepath.Separator) { + return base, nil + } + } + return "", fmt.Errorf("required SSH key title is missing; use --title or provide --from") +} + +func publicKeyContent(inline, from string) (string, error) { + inline = strings.TrimSpace(inline) + from = strings.TrimSpace(from) + if inline != "" && from != "" { + return "", fmt.Errorf("use either --key or --from, not both") + } + if from != "" { + content, err := os.ReadFile(from) + if err != nil { + return "", fmt.Errorf("read SSH public key file: %w", err) + } + inline = strings.TrimSpace(string(content)) + } + if inline == "" { + return "", fmt.Errorf("required SSH public key content is missing; use --key or --from") + } + if !hasPublicKeyPrefix(inline) { + return "", fmt.Errorf("SSH public key content should start with ssh-rsa, ssh-dss, ssh-ed25519, ecdsa-sha2-, or sk-") + } + return inline, nil +} + +func hasPublicKeyPrefix(key string) bool { + for _, prefix := range []string{"ssh-rsa", "ssh-dss", "ssh-ed25519", "ecdsa-sha2-", "sk-"} { + if strings.HasPrefix(key, prefix) { + return true + } + } + return false +} diff --git a/shortcuts/user/user_test.go b/shortcuts/user/user_test.go index 44d504f..3aad3ab 100644 --- a/shortcuts/user/user_test.go +++ b/shortcuts/user/user_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -34,9 +36,12 @@ func findShortcut(t *testing.T, name string) *common.Shortcut { return nil } -func writeJSON(w http.ResponseWriter, v interface{}) { +func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) { + t.Helper() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("write response: %v", err) + } } // --- me --- @@ -46,7 +51,7 @@ func TestUserMe(t *testing.T) { if r.URL.Path != "/users/me.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "login": "currentuser", "name": "Current User", "id": float64(1), @@ -67,7 +72,7 @@ func TestUserInfo(t *testing.T) { if r.URL.Path != "/users/alice.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "login": "alice", "name": "Alice", }) @@ -92,6 +97,192 @@ func TestUserInfoMissingLogin(t *testing.T) { } } +// --- SSH public keys --- + +func TestUserKeysUsesPublicKeysEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/public_keys.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Fatalf("page query = %q, want 2", got) + } + if got := r.URL.Query().Get("limit"); got != "50" { + t.Fatalf("limit query = %q, want 50", got) + } + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "public_keys": []interface{}{ + map[string]interface{}{"id": 1, "name": "laptop"}, + }, + }) + })) + defer server.Close() + + err := runShortcut(t, server, "keys", map[string]string{ + "page": "2", + "limit": "50", + }) + if err != nil { + t.Fatalf("keys shortcut failed: %v", err) + } +} + +func TestUserAddKeySendsTitleAndInlineKey(t *testing.T) { + const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDemoKey user@example.com" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/public_keys.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + if body["title"] != "laptop" { + t.Fatalf("title = %q, want laptop", body["title"]) + } + if body["key"] != key { + t.Fatalf("key = %q, want %q", body["key"], key) + } + writeJSON(t, w, map[string]interface{}{ + "id": 2, + "name": "laptop", + "fingerprint": "SHA256:demo", + }) + })) + defer server.Close() + + err := runShortcut(t, server, "add-key", map[string]string{ + "title": "laptop", + "key": key, + }) + if err != nil { + t.Fatalf("add-key shortcut failed: %v", err) + } +} + +func TestUserAddKeyReadsKeyFromFileAndDefaultsTitle(t *testing.T) { + const key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDemo user@example.com" + path := filepath.Join(t.TempDir(), "id_rsa.pub") + if err := os.WriteFile(path, []byte(" "+key+"\n"), 0o600); err != nil { + t.Fatalf("write key file: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/public_keys.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode request: %v", err) + } + if body["title"] != "id_rsa.pub" { + t.Fatalf("title = %q, want id_rsa.pub", body["title"]) + } + if body["key"] != key { + t.Fatalf("key = %q, want %q", body["key"], key) + } + writeJSON(t, w, map[string]interface{}{"id": 3}) + })) + defer server.Close() + + err := runShortcut(t, server, "add-key", map[string]string{"from": path}) + if err != nil { + t.Fatalf("add-key from file failed: %v", err) + } +} + +func TestUserAddKeyRejectsAmbiguousKeySourcesBeforeRequest(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + defer server.Close() + + err := runShortcut(t, server, "add-key", map[string]string{ + "title": "laptop", + "key": "ssh-ed25519 AAAA", + "from": "id_ed25519.pub", + }) + if err == nil { + t.Fatalf("expected ambiguous key source error") + } + if called { + t.Fatalf("server was called for invalid key sources") + } +} + +func TestUserAddKeyRejectsInlineKeyWithoutTitleBeforeRequest(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + defer server.Close() + + err := runShortcut(t, server, "add-key", map[string]string{"key": "ssh-rsa AAAA"}) + if err == nil { + t.Fatalf("expected missing title error") + } + if called { + t.Fatalf("server was called without title") + } +} + +func TestUserDeleteKeyUsesPublicKeyID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" || r.URL.Path != "/public_keys/12.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "success", + }) + })) + defer server.Close() + + if err := runShortcut(t, server, "delete-key", map[string]string{"id": "12"}); err != nil { + t.Fatalf("delete-key shortcut failed: %v", err) + } +} + +func TestUserDeleteKeyRejectsNonNumericIDBeforeRequest(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + defer server.Close() + + err := runShortcut(t, server, "delete-key", map[string]string{"id": "12/34"}) + if err == nil { + t.Fatalf("expected invalid key id error") + } + if called { + t.Fatalf("server was called for invalid key id") + } +} + +func TestPublicKeyContentAcceptsCommonOpenSSHPrefixes(t *testing.T) { + for _, key := range []string{ + "ssh-rsa AAAA", + "ssh-dss AAAA", + "ssh-ed25519 AAAA", + "ecdsa-sha2-nistp256 AAAA", + "sk-ssh-ed25519@openssh.com AAAA", + } { + t.Run(key, func(t *testing.T) { + if _, err := publicKeyContent(key, ""); err != nil { + t.Fatalf("publicKeyContent(%q) returned error: %v", key, err) + } + }) + } +} + +func TestPublicKeyContentRejectsNonPublicKeyPrefix(t *testing.T) { + if _, err := publicKeyContent("not-a-key", ""); err == nil { + t.Fatalf("expected invalid public key prefix error") + } +} + // --- HTTP error paths --- func TestUserMeHTTPError(t *testing.T) { diff --git a/skills/README.md b/skills/README.md index d507074..d49d266 100644 --- a/skills/README.md +++ b/skills/README.md @@ -139,7 +139,7 @@ skills/ | Skill | 说明 | 常用命令 | |-------|------|----------| | **gitlink-search** | 搜索功能 | `search +repos`, `search +users` | -| **gitlink-user** | 用户管理 | `user +me`, `user +info` | +| **gitlink-user** | 用户管理 | `user +me`, `user +info`, `user +keys`, `user +add-key`, `user +delete-key` | | **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` | | **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` | | **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` | diff --git a/skills/gitlink-user/SKILL.md b/skills/gitlink-user/SKILL.md index 0d1aa4f..f203aa2 100644 --- a/skills/gitlink-user/SKILL.md +++ b/skills/gitlink-user/SKILL.md @@ -23,6 +23,20 @@ metadata: | `user +me` | 当前登录用户 | 是 | | `user +info` | 查看用户详情 | 否 | +## SSH Public Keys + +Use these shortcuts to manage SSH public keys for the current authenticated user: + +```bash +gitlink-cli user +keys +gitlink-cli user +add-key --title laptop --from ~/.ssh/id_ed25519.pub +gitlink-cli user +add-key --from ~/.ssh/id_rsa.pub +gitlink-cli user +add-key --title laptop --key "ssh-ed25519 AAAA..." +gitlink-cli user +delete-key --id 123 +``` + +`user +add-key` accepts either `--key` or `--from`, not both. When `--from` is used without `--title`, the filename is used as the default title. `user +delete-key` requires a positive integer key ID. + ## 使用示例 ```bash