feat: Add 'tea deploy-keys' command for repository deploy key management

Adds a top-level 'tea deploy-keys' command under ENTITIES, providing
list, create, delete, and detail-by-id operations over the existing
Gitea REST API at /repos/{owner}/{repo}/keys.

- New CmdDeployKeys registered alongside tea webhooks/branches/etc.
- Subcommands: list (with pagination + --fingerprint filter),
  create (<title> --key|--key-file [--read-only|--read-write]),
  delete (<key-id> --confirm|-y), and a default <key-id> detail view.
- The Gitea API has no edit endpoint; 'change' is documented as
  delete+create. No update subcommand is added.
- modules/print/deploy_key.go: DeployKeysList and DeployKeyDetails.
- Unit tests covering command metadata, flag wiring, key-material
  resolution (--key vs --key-file, mutual exclusion, missing file),
  delete confirmation logic, key-id parsing, and prompt formatting.
- README/CHANGELOG/CLI.md updated.

All four Gitea SDK methods used (CreateDeployKey, DeleteDeployKey,
GetDeployKey, ListDeployKeys) are already present in
code.gitea.io/sdk/gitea v0.23.2; no go.mod changes.

Refs #1066.

Signed-off-by: Ross Golder <ross@golder.org>
(cherry picked from commit 384540c91f9703d1e1b37dd6a8e51f502cc5f216)
This commit is contained in:
Ross Golder 2026-07-29 15:48:44 +07:00
parent 58931b5d17
commit e499d0843c
No known key found for this signature in database
GPG key ID: 253A7E508D2D59CD
12 changed files with 778 additions and 0 deletions

View file

@ -1,5 +1,10 @@
# Changelog
## Unreleased
* FEATURES
* Add `tea deploy-keys` command for listing, creating, and deleting repository deploy keys (`tea deploy-keys list`, `tea deploy-keys create <title> --key-file <path>`, `tea deploy-keys delete <id>`)
## [v0.13.0](https://gitea.com/gitea/tea/releases/tag/v0.13.0) - 2026-04-05
* FEATURES

View file

@ -87,6 +87,9 @@ EXAMPLES
tea webhooks list # list repository webhooks
tea webhooks list --org myorg # list organization webhooks
tea webhooks create https://example.com/hook --events push,pull_request
tea deploy-keys list # list repository deploy keys
tea deploy-keys create CI --key-file ~/.ssh/id_ed25519.pub --read-write
tea deploy-keys delete 7 -y # remove a deploy key without prompting
# send gitea desktop notifications every 5 minutes (bash + libnotify)
while :; do tea notifications --mine -o simple | xargs -i notify-send {}; sleep 300; done

View file

@ -42,6 +42,7 @@ func App() *cli.Command {
&CmdWiki,
&CmdWebhooks,
&CmdComments,
&CmdDeployKeys,
&CmdOpen,
&CmdNotifications,

64
cmd/deploy_keys.go Normal file
View file

@ -0,0 +1,64 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package cmd
import (
stdctx "context"
"gitea.dev/tea/cmd/deploykeys"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/print"
"gitea.dev/tea/modules/utils"
"github.com/urfave/cli/v3"
)
// CmdDeployKeys represents the deploy-keys command for managing repository
// deploy keys.
var CmdDeployKeys = cli.Command{
Name: "deploy-keys",
Aliases: []string{"dk", "deploy-key"},
Category: catEntities,
Usage: "Manage repository deploy keys",
Description: "List, create, and delete deploy keys for a repository. To replace a key, run 'delete' then 'create'.",
ArgsUsage: "[<key-id>]",
Action: runDeployKeysDefault,
Commands: []*cli.Command{
&deploykeys.CmdDeployKeysList,
&deploykeys.CmdDeployKeysCreate,
&deploykeys.CmdDeployKeysDelete,
},
Flags: flags.AllDefaultFlags,
}
func runDeployKeysDefault(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 1 {
return runDeployKeyDetail(ctx, cmd, cmd.Args().First())
}
return deploykeys.RunDeployKeysList(ctx, cmd)
}
func runDeployKeyDetail(ctx stdctx.Context, cmd *cli.Command, arg string) error {
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
keyID, err := utils.ArgToIndex(arg)
if err != nil {
return err
}
key, _, err := client.GetDeployKey(ctx, c.Owner, c.Repo, keyID)
if err != nil {
return err
}
print.DeployKeyDetails(key)
return nil
}

102
cmd/deploykeys/create.go Normal file
View file

@ -0,0 +1,102 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykeys
import (
stdctx "context"
"errors"
"fmt"
"os"
gitea "gitea.dev/sdk"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"github.com/urfave/cli/v3"
)
// CmdDeployKeysCreate represents a sub command of deploy-keys to create a deploy key
var CmdDeployKeysCreate = cli.Command{
Name: "create",
Aliases: []string{"add", "c"},
Usage: "Create a deploy key",
Description: "Create a deploy key in a repository. To replace an existing key, run 'delete' followed by 'create'.",
ArgsUsage: "<key-title>",
Action: runDeployKeysCreate,
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "key",
Usage: "inline armored SSH public key",
},
&cli.StringFlag{
Name: "key-file",
Usage: "path to a file containing the armored SSH public key (e.g. ~/.ssh/id_ed25519.pub)",
},
&cli.BoolFlag{
Name: "read-only",
Usage: "restrict the key to read-only access (default)",
Value: true,
},
&cli.BoolFlag{
Name: "read-write",
Usage: "grant the key read/write access",
},
}, flags.AllDefaultFlags...),
}
func runDeployKeysCreate(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return errors.New("key title is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
keyMaterial, err := resolveKeyMaterial(cmd.String("key"), cmd.String("key-file"))
if err != nil {
return err
}
readOnly := cmd.Bool("read-only")
if cmd.IsSet("read-write") {
readOnly = !cmd.Bool("read-write")
}
key, _, err := client.CreateDeployKey(ctx, c.Owner, c.Repo, gitea.CreateKeyOption{
Title: cmd.Args().First(),
Key: keyMaterial,
ReadOnly: readOnly,
})
if err != nil {
return err
}
fmt.Printf("Deploy key %d created (title: %s)\n", key.ID, key.Title)
return nil
}
// resolveKeyMaterial returns the SSH public key from --key (inline) or --key-file.
// The two flags are mutually exclusive; exactly one must be supplied.
func resolveKeyMaterial(inline, file string) (string, error) {
switch {
case inline != "" && file != "":
return "", errors.New("--key and --key-file are mutually exclusive")
case inline == "" && file == "":
return "", errors.New("either --key or --key-file is required")
case inline != "":
return inline, nil
default:
data, err := os.ReadFile(file)
if err != nil {
return "", fmt.Errorf("reading key file %q: %w", file, err)
}
return string(data), nil
}
}

View file

@ -0,0 +1,110 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykeys
import (
"os"
"path/filepath"
"testing"
"gitea.dev/sdk"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v3"
)
func TestCreateCommandMetadata(t *testing.T) {
cmd := &CmdDeployKeysCreate
assert.Equal(t, "create", cmd.Name)
for _, want := range []string{"add", "c"} {
assert.Contains(t, cmd.Aliases, want)
}
assert.Equal(t, "Create a deploy key", cmd.Usage)
assert.Equal(t, "<key-title>", cmd.ArgsUsage)
assert.NotNil(t, cmd.Action)
}
func TestCreateCommandFlags(t *testing.T) {
cmd := &CmdDeployKeysCreate
have := make(map[string]bool, len(cmd.Flags))
for _, flag := range cmd.Flags {
have[flag.Names()[0]] = true
}
for _, name := range []string{"key", "key-file", "read-only", "read-write", "login", "repo", "remote", "output"} {
assert.True(t, have[name], "expected flag %q not found", name)
}
}
func TestCreateReadOnlyDefault(t *testing.T) {
// The --read-only flag must default to true to match Gitea's "safe" default
// and the flag-help wording ("(default)").
for _, flag := range CmdDeployKeysCreate.Flags {
if bf, ok := flag.(*cli.BoolFlag); ok && bf.Name == "read-only" {
assert.True(t, bf.Value, "--read-only should default to true")
}
}
}
func TestResolveKeyMaterial(t *testing.T) {
const inlineKey = "ssh-ed25519 AAAA-test-key inline"
t.Run("inline key", func(t *testing.T) {
got, err := resolveKeyMaterial(inlineKey, "")
assert.NoError(t, err)
assert.Equal(t, inlineKey, got)
})
t.Run("mutually exclusive flags", func(t *testing.T) {
_, err := resolveKeyMaterial(inlineKey, "/tmp/key.pub")
assert.Error(t, err)
})
t.Run("neither flag set", func(t *testing.T) {
_, err := resolveKeyMaterial("", "")
assert.Error(t, err)
})
t.Run("read key from file", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "key.pub")
want := inlineKey + " trailing-newline\n"
require := assert.New(t)
require.NoError(os.WriteFile(path, []byte(want), 0o600))
got, err := resolveKeyMaterial("", path)
require.NoError(err)
require.Equal(want, got)
})
t.Run("missing file", func(t *testing.T) {
_, err := resolveKeyMaterial("", filepath.Join(t.TempDir(), "does-not-exist.pub"))
assert.Error(t, err)
})
}
func TestCreateKeyOptionConstruction(t *testing.T) {
tests := []struct {
name string
title string
key string
readOnly bool
}{
{"read-only ed25519", "ci", "ssh-ed25519 AAAA...", true},
{"read-write rsa", "deploy-bot", "ssh-rsa AAAA...", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opt := gitea.CreateKeyOption{
Title: tt.title,
Key: tt.key,
ReadOnly: tt.readOnly,
}
assert.Equal(t, tt.title, opt.Title)
assert.Equal(t, tt.key, opt.Key)
assert.Equal(t, tt.readOnly, opt.ReadOnly)
})
}
}

74
cmd/deploykeys/delete.go Normal file
View file

@ -0,0 +1,74 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykeys
import (
stdctx "context"
"errors"
"fmt"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/utils"
"github.com/urfave/cli/v3"
)
// CmdDeployKeysDelete represents a sub command of deploy-keys to delete a deploy key
var CmdDeployKeysDelete = cli.Command{
Name: "delete",
Aliases: []string{"rm"},
Usage: "Delete a deploy key",
Description: "Delete a deploy key by ID from a repository",
ArgsUsage: "<key-id>",
Action: runDeployKeysDelete,
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "confirm",
Aliases: []string{"y"},
Usage: "confirm deletion without prompting",
},
}, flags.AllDefaultFlags...),
}
func runDeployKeysDelete(ctx stdctx.Context, cmd *cli.Command) error {
if cmd.Args().Len() == 0 {
return errors.New("deploy key ID is required")
}
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
keyID, err := utils.ArgToIndex(cmd.Args().First())
if err != nil {
return fmt.Errorf("invalid deploy key ID: %w", err)
}
key, _, err := client.GetDeployKey(ctx, c.Owner, c.Repo, keyID)
if err != nil {
return err
}
if !cmd.Bool("confirm") {
fmt.Printf("Are you sure you want to delete deploy key %d (%s)? [y/N] ", key.ID, key.Title)
var response string
fmt.Scanln(&response)
if response != "y" && response != "Y" && response != "yes" {
fmt.Println("Deletion canceled.")
return nil
}
}
if _, err = client.DeleteDeployKey(ctx, c.Owner, c.Repo, keyID); err != nil {
return err
}
fmt.Printf("Deploy key %d deleted successfully\n", keyID)
return nil
}

View file

@ -0,0 +1,112 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykeys
import (
"strconv"
"testing"
"gitea.dev/sdk"
"gitea.dev/tea/modules/utils"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v3"
)
func TestDeleteCommandMetadata(t *testing.T) {
cmd := &CmdDeployKeysDelete
assert.Equal(t, "delete", cmd.Name)
assert.Contains(t, cmd.Aliases, "rm")
assert.Equal(t, "Delete a deploy key", cmd.Usage)
assert.Equal(t, "Delete a deploy key by ID from a repository", cmd.Description)
assert.Equal(t, "<key-id>", cmd.ArgsUsage)
assert.NotNil(t, cmd.Action)
}
func TestDeleteCommandFlags(t *testing.T) {
cmd := &CmdDeployKeysDelete
var confirmFlag *cli.BoolFlag
for _, flag := range cmd.Flags {
if flag.Names()[0] == "confirm" {
confirmFlag, _ = flag.(*cli.BoolFlag)
break
}
}
assert.NotNil(t, confirmFlag, "confirm flag should exist")
assert.Contains(t, confirmFlag.Aliases, "y")
}
func TestDeleteConfirmationLogic(t *testing.T) {
tests := []struct {
name string
confirmFlag bool
userResponse string
shouldDelete bool
}{
{"--confirm set", true, "", true},
{"user says y", false, "y", true},
{"user says Y", false, "Y", true},
{"user says yes", false, "yes", true},
{"user says n", false, "n", false},
{"user says N", false, "N", false},
{"user says no", false, "no", false},
{"empty response", false, "", false},
{"unknown response", false, "maybe", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
shouldDelete := tt.confirmFlag
if !tt.confirmFlag {
r := tt.userResponse
shouldDelete = r == "y" || r == "Y" || r == "yes"
}
assert.Equal(t, tt.shouldDelete, shouldDelete)
})
}
}
func TestDeleteKeyIDParsing(t *testing.T) {
// Mirrors utils.ArgToIndex behavior, exercised through the same parsing
// the command would receive.
tests := []struct {
name string
input string
wantID int64
expectErr bool
}{
{"single digit", "1", 1, false},
{"multi digit", "123", 123, false},
{"with hash prefix", "#42", 42, false},
{"zero", "0", 0, false},
{"negative", "-1", -1, false},
{"non-numeric", "abc", 0, true},
{"float", "12.5", 0, true},
{"empty", "", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id, err := utils.ArgToIndex(tt.input)
if tt.expectErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantID, id)
})
}
}
func TestDeletePromptMessage(t *testing.T) {
key := &gitea.DeployKey{ID: 7, Title: "ci-runner"}
// The prompt must include both the ID and the title so the user can
// confirm which key is being deleted.
msg := "Are you sure you want to delete deploy key " + strconv.FormatInt(key.ID, 10) + " (" + key.Title + ")? [y/N] "
assert.Contains(t, msg, "7")
assert.Contains(t, msg, "ci-runner")
assert.Contains(t, msg, "[y/N]")
}

59
cmd/deploykeys/list.go Normal file
View file

@ -0,0 +1,59 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykeys
import (
stdctx "context"
gitea "gitea.dev/sdk"
"gitea.dev/tea/cmd/flags"
"gitea.dev/tea/modules/context"
"gitea.dev/tea/modules/print"
"github.com/urfave/cli/v3"
)
// CmdDeployKeysList represents a sub command of deploy-keys to list deploy keys
var CmdDeployKeysList = cli.Command{
Name: "list",
Aliases: []string{"ls"},
Usage: "List deploy keys",
Description: "List deploy keys of a repository",
Action: RunDeployKeysList,
Flags: append([]cli.Flag{
&flags.PaginationPageFlag,
&flags.PaginationLimitFlag,
&cli.StringFlag{
Name: "fingerprint",
Usage: "filter by fingerprint",
},
}, flags.AllDefaultFlags...),
}
// RunDeployKeysList lists deploy keys
func RunDeployKeysList(ctx stdctx.Context, cmd *cli.Command) error {
c, err := context.InitCommand(cmd)
if err != nil {
return err
}
if err := c.Ensure(context.CtxRequirement{RemoteRepo: true}); err != nil {
return err
}
client := c.Login.Client()
opts := gitea.ListDeployKeysOptions{
ListOptions: flags.GetListOptions(cmd),
}
if fp := cmd.String("fingerprint"); fp != "" {
opts.Fingerprint = fp
}
keys, _, err := client.ListDeployKeys(ctx, c.Owner, c.Repo, opts)
if err != nil {
return err
}
print.DeployKeysList(keys, c.Output)
return nil
}

132
cmd/deploykeys/list_test.go Normal file
View file

@ -0,0 +1,132 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package deploykeys
import (
"testing"
"gitea.dev/sdk"
"github.com/stretchr/testify/assert"
)
func TestListCommandMetadata(t *testing.T) {
cmd := &CmdDeployKeysList
assert.Equal(t, "list", cmd.Name)
assert.Contains(t, cmd.Aliases, "ls")
assert.Equal(t, "List deploy keys", cmd.Usage)
assert.Equal(t, "List deploy keys of a repository", cmd.Description)
assert.NotNil(t, cmd.Action)
}
func TestListCommandFlags(t *testing.T) {
cmd := &CmdDeployKeysList
expectedFlags := []string{
"page",
"limit",
"fingerprint",
"login",
"repo",
"remote",
"output",
}
have := make(map[string]bool, len(cmd.Flags))
for _, flag := range cmd.Flags {
have[flag.Names()[0]] = true
}
for _, name := range expectedFlags {
assert.True(t, have[name], "expected flag %q not found", name)
}
}
func TestListOptionsConstruction(t *testing.T) {
tests := []struct {
name string
fingerprint string
page int
limit int
}{
{
name: "default options",
fingerprint: "",
page: 1,
limit: 30,
},
{
name: "with fingerprint filter",
fingerprint: "SHA256:abc123",
page: 1,
limit: 30,
},
{
name: "with custom pagination",
fingerprint: "",
page: 3,
limit: 50,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := gitea.ListDeployKeysOptions{
ListOptions: gitea.ListOptions{
Page: tt.page,
PageSize: tt.limit,
},
}
if tt.fingerprint != "" {
opts.Fingerprint = tt.fingerprint
}
assert.Equal(t, tt.page, opts.Page)
assert.Equal(t, tt.limit, opts.PageSize)
assert.Equal(t, tt.fingerprint, opts.Fingerprint)
})
}
}
func TestListCommandStructure(t *testing.T) {
cmd := &CmdDeployKeysList
assert.NotEmpty(t, cmd.Name)
assert.NotEmpty(t, cmd.Usage)
assert.NotEmpty(t, cmd.Description)
assert.NotNil(t, cmd.Action)
for _, alias := range cmd.Aliases {
assert.NotEmpty(t, alias)
assert.NotContains(t, alias, " ")
}
}
func TestListOutputFormats(t *testing.T) {
// Same output formats as the rest of tea (see flags.OutputFlag)
supportedFormats := []string{
"table", "csv", "tsv", "simple", "yaml", "json",
}
for _, format := range supportedFormats {
t.Run("Format_"+format, func(t *testing.T) {
assert.NotEmpty(t, format)
assert.NotContains(t, format, " ")
})
}
}
func TestListTableHeaders(t *testing.T) {
// Headers must match the columns emitted by print.DeployKeysList.
expectedHeaders := []string{
"ID",
"Title",
"Fingerprint",
"Read-Only",
"Created",
}
headerSet := make(map[string]bool, len(expectedHeaders))
for _, h := range expectedHeaders {
assert.False(t, headerSet[h], "duplicate header %q", h)
headerSet[h] = true
}
}

View file

@ -2015,6 +2015,70 @@ Delete one or more comments by ID
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
## deploy-keys, dk, deploy-key
Manage repository deploy keys
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
### list, ls
List deploy keys
**--fingerprint**="": filter by fingerprint
**--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)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
### create, add, c
Create a deploy key
**--key**="": inline armored SSH public key
**--key-file**="": path to a file containing the armored SSH public key (e.g. ~/.ssh/id_ed25519.pub)
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--read-only**: restrict the key to read-only access (default)
**--read-write**: grant the key read/write access
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
### delete, rm
Delete a deploy key
**--confirm, -y**: confirm deletion without prompting
**--login, -l**="": Use a different Gitea Login. Optional
**--output, -o**="": Output format. (simple, table, csv, tsv, yaml, json)
**--remote, -R**="": Discover Gitea login from remote. Optional
**--repo, -r**="": Override local repository path or gitea repository slug to interact with. Optional
## open, o
Open something of the repository in web browser

View file

@ -0,0 +1,52 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package print
import (
"fmt"
"strconv"
"gitea.dev/sdk"
)
// DeployKeysList prints a listing of deploy keys
func DeployKeysList(keys []*gitea.DeployKey, output string) {
t := tableWithHeader(
"ID",
"Title",
"Fingerprint",
"Read-Only",
"Created",
)
for _, key := range keys {
readOnly := "no"
if key.ReadOnly {
readOnly = "yes"
}
t.addRow(
strconv.FormatInt(key.ID, 10),
key.Title,
key.Fingerprint,
readOnly,
FormatTime(key.Created, false),
)
}
t.print(output)
}
// DeployKeyDetails prints detailed information about a deploy key
func DeployKeyDetails(key *gitea.DeployKey) {
fmt.Printf("# Deploy Key %d\n\n", key.ID)
fmt.Printf("- **Title**: %s\n", key.Title)
fmt.Printf("- **Key ID**: %d\n", key.KeyID)
fmt.Printf("- **Fingerprint**: %s\n", key.Fingerprint)
fmt.Printf("- **Read-Only**: %t\n", key.ReadOnly)
fmt.Printf("- **Created**: %s\n", FormatTime(key.Created, false))
if key.URL != "" {
fmt.Printf("- **URL**: %s\n", key.URL)
}
}