diff --git a/cmd/cmd.go b/cmd/cmd.go index 243f2b9..0171b8e 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -45,6 +45,8 @@ func App() *cli.Command { &CmdNotifications, &CmdRepoClone, + &CmdSSHKeys, + &CmdAdmin, &CmdApi, diff --git a/cmd/sshkeys.go b/cmd/sshkeys.go new file mode 100644 index 0000000..d18e2de --- /dev/null +++ b/cmd/sshkeys.go @@ -0,0 +1,32 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + stdctx "context" + + "code.gitea.io/tea/cmd/sshkeys" + "github.com/urfave/cli/v3" +) + +// CmdSSHKeys represents the ssh-keys command group +var CmdSSHKeys = cli.Command{ + Name: "ssh-keys", + Aliases: []string{"ssh-key"}, + Category: catSetup, + Usage: "Manage SSH public keys", + Description: "List, add, or delete SSH public keys on the current user's account", + ArgsUsage: " ", + Action: runSSHKeys, + Commands: []*cli.Command{ + &sshkeys.CmdSSHKeyList, + &sshkeys.CmdSSHKeyAdd, + &sshkeys.CmdSSHKeyDelete, + }, + Flags: sshkeys.CmdSSHKeyList.Flags, +} + +func runSSHKeys(ctx stdctx.Context, cmd *cli.Command) error { + return sshkeys.RunSSHKeyList(ctx, cmd) +} diff --git a/cmd/sshkeys/add.go b/cmd/sshkeys/add.go new file mode 100644 index 0000000..5294404 --- /dev/null +++ b/cmd/sshkeys/add.go @@ -0,0 +1,74 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sshkeys + +import ( + stdctx "context" + "fmt" + "os" + "path/filepath" + "strings" + + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdSSHKeyAdd represents a sub command of ssh-keys to add an SSH public key +var CmdSSHKeyAdd = cli.Command{ + Name: "add", + Usage: "Add an SSH public key", + Description: "Add an SSH public key to the current user's profile", + ArgsUsage: "", + Action: RunSSHKeyAdd, + Flags: append([]cli.Flag{ + &cli.StringFlag{ + Name: "title", + Aliases: []string{"t"}, + Usage: "Title for the key (defaults to the filename without extension)", + }, + }, flags.LoginOutputFlags...), +} + +// RunSSHKeyAdd reads a public key file and registers it with the Gitea instance +func RunSSHKeyAdd(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + if ctx.Args().Len() < 1 { + return fmt.Errorf("key file path is required") + } + + keyFile := ctx.Args().First() + keyBytes, err := os.ReadFile(keyFile) + if err != nil { + return fmt.Errorf("could not read key file '%s': %w", keyFile, err) + } + + keyContent := strings.TrimSpace(string(keyBytes)) + if keyContent == "" { + return fmt.Errorf("key file '%s' is empty", keyFile) + } + + title := ctx.String("title") + if title == "" { + base := filepath.Base(keyFile) + title = strings.TrimSuffix(base, filepath.Ext(base)) + } + + key, _, err := ctx.Login.Client().CreatePublicKey(gitea.CreateKeyOption{ + Title: title, + Key: keyContent, + }) + if err != nil { + return err + } + + fmt.Printf("Key '%s' (id: %d) added successfully.\n", key.Title, key.ID) + return nil +} diff --git a/cmd/sshkeys/add_test.go b/cmd/sshkeys/add_test.go new file mode 100644 index 0000000..40c8147 --- /dev/null +++ b/cmd/sshkeys/add_test.go @@ -0,0 +1,33 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sshkeys + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestKeyTitleFromFilename(t *testing.T) { + cases := []struct { + input string + expected string + }{ + {"id_ed25519.pub", "id_ed25519"}, + {"id_rsa.pub", "id_rsa"}, + {"/home/user/.ssh/id_ed25519.pub", "id_ed25519"}, + {"mykey", "mykey"}, // no extension + {"my.key.pub", "my.key"}, + } + + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + base := filepath.Base(tc.input) + title := strings.TrimSuffix(base, filepath.Ext(base)) + assert.Equal(t, tc.expected, title) + }) + } +} diff --git a/cmd/sshkeys/delete.go b/cmd/sshkeys/delete.go new file mode 100644 index 0000000..9c02784 --- /dev/null +++ b/cmd/sshkeys/delete.go @@ -0,0 +1,76 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sshkeys + +import ( + stdctx "context" + "fmt" + "strconv" + + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + + "github.com/urfave/cli/v3" +) + +// CmdSSHKeyDelete represents a sub command of ssh-keys to delete an SSH key by ID +var CmdSSHKeyDelete = cli.Command{ + Name: "delete", + Aliases: []string{"rm"}, + Usage: "Delete an SSH key", + Description: "Delete an SSH key from the current user's profile by its numeric ID", + ArgsUsage: "", + Action: RunSSHKeyDelete, + Flags: append([]cli.Flag{ + &cli.BoolFlag{ + Name: "confirm", + Aliases: []string{"y"}, + Usage: "Confirm deletion (required)", + }, + }, flags.LoginOutputFlags...), +} + +// RunSSHKeyDelete removes an SSH key by its numeric ID +func RunSSHKeyDelete(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + + if ctx.Args().Len() < 1 { + return fmt.Errorf("key ID is required") + } + + keyID, err := strconv.ParseInt(ctx.Args().First(), 10, 64) + if err != nil { + return fmt.Errorf("invalid key ID '%s': must be a number", ctx.Args().First()) + } + + client := ctx.Login.Client() + + key, resp, err := client.GetPublicKey(keyID) + if err != nil { + if resp != nil && resp.StatusCode == 404 { + return fmt.Errorf("SSH key with ID %d not found", keyID) + } + return err + } + + if !ctx.Bool("confirm") { + fmt.Printf("Are you sure you want to delete SSH key '%s' (id: %d)? [y/N] ", key.Title, keyID) + var response string + fmt.Scanln(&response) + if response != "y" && response != "Y" && response != "yes" { + fmt.Println("Deletion canceled.") + return nil + } + } + + if _, err = client.DeletePublicKey(keyID); err != nil { + return err + } + + fmt.Printf("SSH key '%s' deleted successfully\n", key.Title) + return nil +} diff --git a/cmd/sshkeys/list.go b/cmd/sshkeys/list.go new file mode 100644 index 0000000..f28aaa1 --- /dev/null +++ b/cmd/sshkeys/list.go @@ -0,0 +1,47 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package sshkeys + +import ( + stdctx "context" + + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/cmd/flags" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/print" + + "github.com/urfave/cli/v3" +) + +// CmdSSHKeyList represents a sub command of ssh-keys to list the current user's SSH keys +var CmdSSHKeyList = cli.Command{ + Name: "list", + Aliases: []string{"ls"}, + Usage: "List SSH keys", + Description: "List the SSH keys registered for the current user", + ArgsUsage: " ", // command does not accept arguments + Action: RunSSHKeyList, + Flags: append([]cli.Flag{ + &flags.PaginationPageFlag, + &flags.PaginationLimitFlag, + }, flags.LoginOutputFlags...), +} + +// RunSSHKeyList lists SSH keys for the current user +func RunSSHKeyList(_ stdctx.Context, cmd *cli.Command) error { + ctx, err := context.InitCommand(cmd) + if err != nil { + return err + } + client := ctx.Login.Client() + + keys, _, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{ + ListOptions: flags.GetListOptions(cmd), + }) + if err != nil { + return err + } + + return print.SSHKeysList(keys, ctx.Output) +} diff --git a/docs/CLI.md b/docs/CLI.md index 84da431..9950ada 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1941,6 +1941,50 @@ Clone a repository locally **--login, -l**="": Use a different Gitea Login. Optional +## ssh-keys, ssh-key + +Manage SSH public keys + +**--limit, --lm**="": specify limit of items per page (default: 30) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--page, -p**="": specify page (default: 1) + +### list, ls + +List SSH keys + +**--limit, --lm**="": specify limit of items per page (default: 30) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--page, -p**="": specify page (default: 1) + +### add + +Add an SSH public key + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + +**--title, -t**="": Title for the key (defaults to the filename without extension) + +### delete, rm + +Delete an SSH key + +**--confirm, -y**: Confirm deletion (required) + +**--login, -l**="": Use a different Gitea Login. Optional + +**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json) + ## admin, a Operations requiring admin access on the Gitea instance diff --git a/modules/print/sshkey.go b/modules/print/sshkey.go new file mode 100644 index 0000000..f08376b --- /dev/null +++ b/modules/print/sshkey.go @@ -0,0 +1,44 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package print + +import ( + "fmt" + + "code.gitea.io/sdk/gitea" +) + +// SSHKeysList prints a table of SSH public keys +func SSHKeysList(keys []*gitea.PublicKey, output string) error { + if len(keys) == 0 { + fmt.Printf("No SSH keys found\n") + return nil + } + + t := tableWithHeader( + "ID", + "Title", + "Fingerprint", + "KeyType", + "ReadOnly", + "Created", + ) + + for _, k := range keys { + readOnly := "false" + if k.ReadOnly { + readOnly = "true" + } + t.addRow( + fmt.Sprintf("%d", k.ID), + k.Title, + k.Fingerprint, + k.KeyType, + readOnly, + FormatTime(k.Created, false), + ) + } + + return t.print(output) +} diff --git a/tests/integration/helpers_test.go b/tests/integration/helpers_test.go new file mode 100644 index 0000000..0761ba6 --- /dev/null +++ b/tests/integration/helpers_test.go @@ -0,0 +1,104 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/config" + "code.gitea.io/tea/modules/task" + "github.com/stretchr/testify/require" +) + +var ( + integrationGiteaURL string + integrationUsername string + integrationPassword string + integrationToken string + integrationTokenID int64 + integrationSetupErr error + integrationClient *gitea.Client +) + +func TestMain(m *testing.M) { + integrationGiteaURL = os.Getenv("GITEA_TEA_TEST_URL") + integrationUsername = os.Getenv("GITEA_TEA_TEST_USERNAME") + integrationPassword = os.Getenv("GITEA_TEA_TEST_PASSWORD") + + if integrationGiteaURL != "" { + if integrationUsername == "" || integrationPassword == "" { + integrationSetupErr = fmt.Errorf("GITEA_TEA_TEST_USERNAME and GITEA_TEA_TEST_PASSWORD are required for integration tests") + } else { + integrationClient, integrationSetupErr = gitea.NewClient( + integrationGiteaURL, + gitea.SetBasicAuth(integrationUsername, integrationPassword), + gitea.SetGiteaVersion(""), + ) + if integrationSetupErr == nil { + tokenName := fmt.Sprintf("tea-integration-%d", time.Now().UnixNano()) + var token *gitea.AccessToken + token, _, integrationSetupErr = integrationClient.CreateAccessToken(gitea.CreateAccessTokenOption{ + Name: tokenName, + Scopes: []gitea.AccessTokenScope{gitea.AccessTokenScopeAll}, + }) + if integrationSetupErr == nil { + integrationToken = token.Token + integrationTokenID = token.ID + } + } + } + } + + exitCode := m.Run() + + if integrationClient != nil && integrationTokenID != 0 { + if _, err := integrationClient.DeleteAccessToken(integrationTokenID); err != nil { + fmt.Fprintf(os.Stderr, "failed to delete integration token %d: %v\n", integrationTokenID, err) + if exitCode == 0 { + exitCode = 1 + } + } + } + + os.Exit(exitCode) +} + +func useTempConfigPath(t *testing.T) string { + t.Helper() + + configPath := filepath.Join(t.TempDir(), "config.yml") + config.SetConfigPathForTesting(configPath) + config.SetConfigForTesting(config.LocalConfig{}) + t.Cleanup(func() { + config.SetConfigForTesting(config.LocalConfig{}) + config.SetConfigPathForTesting("") + }) + + return configPath +} + +func createIntegrationLogin(t *testing.T) *config.Login { + t.Helper() + + _ = useTempConfigPath(t) + if integrationGiteaURL == "" { + t.Skip("GITEA_TEA_TEST_URL is not set, skipping integration test") + } + require.NoError(t, integrationSetupErr) + + require.NotEmpty(t, integrationToken, "integration token setup failed") + + require.NoError(t, task.CreateLogin("integration", integrationToken, "", "", "", "", "", integrationGiteaURL, "", "", true, false, false, false)) + + login, err := config.GetLoginByName("integration") + require.NoError(t, err) + require.NotNil(t, login) + + return login +} diff --git a/tests/integration/repos_create_test.go b/tests/integration/repos_create_test.go index 07d79d7..c551e11 100644 --- a/tests/integration/repos_create_test.go +++ b/tests/integration/repos_create_test.go @@ -6,58 +6,18 @@ package integration import ( "context" "fmt" - "os" - "path/filepath" "testing" "time" "code.gitea.io/sdk/gitea" "code.gitea.io/tea/cmd/repos" - "code.gitea.io/tea/modules/config" - "code.gitea.io/tea/modules/task" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" ) -func useTempConfigPath(t *testing.T) string { - t.Helper() - - configPath := filepath.Join(t.TempDir(), "config.yml") - config.SetConfigPathForTesting(configPath) - t.Cleanup(func() { - config.SetConfigPathForTesting("") - }) - - return configPath -} - -func createIntegrationLogin(t *testing.T, giteaURL string) *config.Login { - t.Helper() - - _ = useTempConfigPath(t) - - username := os.Getenv("GITEA_TEA_TEST_USERNAME") - password := os.Getenv("GITEA_TEA_TEST_PASSWORD") - require.NotEmpty(t, username, "GITEA_TEA_TEST_USERNAME is required for integration tests") - require.NotEmpty(t, password, "GITEA_TEA_TEST_PASSWORD is required for integration tests") - - require.NoError(t, task.CreateLogin("integration", "", username, password, "", "", "", giteaURL, "", "", true, false, false, false)) - - login, err := config.GetLoginByName("integration") - require.NoError(t, err) - require.NotNil(t, login) - - return login -} - func TestCreateRepoObjectFormat(t *testing.T) { - giteaURL := os.Getenv("GITEA_TEA_TEST_URL") - if giteaURL == "" { - t.Skip("GITEA_TEA_TEST_URL is not set, skipping test") - } - - login := createIntegrationLogin(t, giteaURL) + login := createIntegrationLogin(t) client := login.Client() timestamp := time.Now().Unix() diff --git a/tests/integration/sshkeys_test.go b/tests/integration/sshkeys_test.go new file mode 100644 index 0000000..f6323d3 --- /dev/null +++ b/tests/integration/sshkeys_test.go @@ -0,0 +1,115 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "strconv" + "testing" + "time" + + "code.gitea.io/sdk/gitea" + sshkeyscmd "code.gitea.io/tea/cmd/sshkeys" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "golang.org/x/crypto/ssh" +) + +// generateTestPublicKey creates a fresh ed25519 keypair and returns a temp +// file path containing the public key in authorized_keys format. +func generateTestPublicKey(t *testing.T) string { + t.Helper() + + _, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + sshPub, err := ssh.NewPublicKey(priv.Public()) + require.NoError(t, err) + + pubKeyStr := fmt.Sprintf("ssh-ed25519 %s tea-test-key", base64.StdEncoding.EncodeToString(sshPub.Marshal())) + + f, err := os.CreateTemp(t.TempDir(), "test-*.pub") + require.NoError(t, err) + _, err = f.WriteString(pubKeyStr) + require.NoError(t, err) + require.NoError(t, f.Close()) + + return f.Name() +} + +func sshKeysCmd() *cli.Command { + return &cli.Command{ + Name: "ssh-keys", + Commands: []*cli.Command{ + &sshkeyscmd.CmdSSHKeyList, + &sshkeyscmd.CmdSSHKeyAdd, + &sshkeyscmd.CmdSSHKeyDelete, + }, + } +} + +func TestSSHKeyAddAndDelete(t *testing.T) { + login := createIntegrationLogin(t) + pubKeyFile := generateTestPublicKey(t) + keyTitle := fmt.Sprintf("tea-test-%d", time.Now().Unix()) + + cmd := sshKeysCmd() + client := login.Client() + + err := cmd.Run(context.Background(), []string{ + "ssh-keys", "add", pubKeyFile, + "--title", keyTitle, + "--login", login.Name, + }) + require.NoError(t, err) + + keys, _, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{ + ListOptions: gitea.ListOptions{Page: -1}, + }) + require.NoError(t, err) + + var addedKey *gitea.PublicKey + for _, key := range keys { + if key.Title == keyTitle { + addedKey = key + break + } + } + require.NotNil(t, addedKey, "added key not found in key list") + + t.Cleanup(func() { + client.DeletePublicKey(addedKey.ID) //nolint:errcheck + }) + + err = cmd.Run(context.Background(), []string{ + "ssh-keys", "delete", strconv.FormatInt(addedKey.ID, 10), + "--confirm", + "--login", login.Name, + }) + assert.NoError(t, err) + + _, resp, err := client.GetPublicKey(addedKey.ID) + assert.Error(t, err) + if assert.NotNil(t, resp) { + assert.Equal(t, 404, resp.StatusCode) + } +} + +func TestSSHKeyList(t *testing.T) { + login := createIntegrationLogin(t) + + cmd := sshKeysCmd() + err := cmd.Run(context.Background(), []string{ + "ssh-keys", "list", + "--login", login.Name, + }) + assert.NoError(t, err) +}