Files
gitea-tea/internal/app/action_values.go
T

427 lines
16 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"regexp"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
var actionValueName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// checkActionValueName rejects names that Gitea refuses for secrets and variables.
func checkActionValueName(name string) error {
if !actionValueName.MatchString(name) {
return clierr.Wrap(clierr.Usage, fmt.Errorf("invalid name %q: use letters, numbers and underscores, not starting with a number", name))
}
if upper := strings.ToUpper(name); upper == "CI" || strings.HasPrefix(upper, "GITEA_") || strings.HasPrefix(upper, "GITHUB_") {
return clierr.Wrap(clierr.Usage, fmt.Errorf("invalid name %q: CI and names starting with GITEA_ or GITHUB_ are reserved", name))
}
return nil
}
// actionScopeFlags select where secrets and variables live: a repository by default, an
// organization with --org, or the user's account with --account.
type actionScopeFlags struct {
org string
account bool
}
// register adds the scope flags to cmd; account adds --account, which secret list lacks.
func (f *actionScopeFlags) register(a *application, cmd *cobra.Command, kind string, account bool) {
cmd.Flags().StringVarP(&f.org, "org", "o", "", "Use the "+kind+"s of an organization instead of a repository")
_ = cmd.RegisterFlagCompletionFunc("org", a.completeOrgs())
if account {
cmd.Flags().BoolVar(&f.account, "account", false, "Use the "+kind+"s of your user account instead of a repository or organization")
}
}
func (f *actionScopeFlags) scope() services.ActionScope {
return services.ActionScope{Organization: f.org, Account: f.account}
}
// resolve selects the scope and the account that manages it.
func (f *actionScopeFlags) resolve(a *application, cmd *cobra.Command) (services.ActionScope, target, error) {
if (f.org != "" && f.account) || ((f.org != "" || f.account) && a.options.repo != "") {
return services.ActionScope{}, target{}, clierr.Wrap(clierr.Usage, errors.New("--org, --account and --repo select different scopes; pass one"))
}
t, err := a.resolve(cmd.Context(), f.org == "" && !f.account)
if err != nil {
return services.ActionScope{}, t, err
}
scope := f.scope()
if f.org == "" && !f.account {
scope.Repository = t.Repo
}
return scope, t, nil
}
func (a *application) actionValueCommand(secret bool) *cobra.Command {
kind, use := "variable", "read with ${{ vars.NAME }}"
if secret {
kind, use = "secret", "read with ${{ secrets.NAME }}"
}
group := &cobra.Command{
Use: kind,
Short: fmt.Sprintf("Manage Actions %ss for a repository, organization, or your account", kind),
Long: fmt.Sprintf(`Manage the Actions %[1]ss that workflows %[2]s.
A %[1]s belongs to a repository, to an organization (--org), whose repositories all see it, or
to your user account (--account), which all your repositories see. Without --org or --account,
tea uses the repository of the working directory or --repo. Names are stored upper-cased.
Note that the global --user flag selects the account tea acts as, not a scope.`, kind, use),
}
group.AddCommand(a.actionValueListCommand(secret, kind), a.actionValueSetCommand(secret, kind), a.actionValueDeleteCommand(secret, kind))
if !secret {
group.AddCommand(a.variableViewCommand(), a.variableGetCommand())
}
return group
}
func (a *application) actionValueListCommand(secret bool, kind string) *cobra.Command {
var scope actionScopeFlags
var limit, page int
example := `# list the variables of the repository
$ tea variable list
# list the variables of an organization
$ tea variable list --org acme`
if secret {
example = `# list the secrets of the repository
$ tea secret list
# list the secrets of an organization
$ tea secret list --org acme`
}
cmd := &cobra.Command{
Use: "list",
Short: "List " + kind + "s",
Example: example,
Args: argsExact(0),
RunE: func(cmd *cobra.Command, _ []string) error {
selected, t, err := scope.resolve(a, cmd)
if err != nil {
return err
}
if secret {
a.accountNote(cmd, t, "Gitea cannot list your account's secrets; set or delete them with --account")
} else {
a.accountNote(cmd, t, "for your account's variables, use --account")
}
where := " of " + selected.String()
if selected.Repository != "" {
where = " in " + selected.Repository
}
pages := listPage{Number: page}
if secret {
items, err := (services.ActionSecrets{Client: t.client, Scope: selected}).ListPage(cmd.Context(), limit, page)
if err != nil {
return err
}
pages.More, pages.Total, pages.Counted = items.More, items.Total, items.Counted
spec := listSpec[services.ActionSecret]{Columns: secretColumns, Row: secretRow, Empty: "no secrets" + where, Context: "secrets" + where}
return writeTable(a, cmd, items.Items, spec, pages)
}
items, err := (services.ActionVariables{Client: t.client, Scope: selected}).ListPage(cmd.Context(), limit, page)
if err != nil {
return err
}
pages.More, pages.Total, pages.Counted = items.More, items.Total, items.Counted
spec := listSpec[services.ActionVariable]{Columns: variableColumns, Row: variableRow, Empty: "no variables" + where, Context: "variables" + where}
return writeTable(a, cmd, items.Items, spec, pages)
},
}
pageFlags(cmd, &limit, &page)
scope.register(a, cmd, kind, !secret)
return cmd
}
var (
secretColumns = []terminal.Column{{Header: "Name"}, {Header: "Created"}, {Header: "Description", Flex: true}}
variableColumns = []terminal.Column{{Header: "Name"}, {Header: "Value", Flex: true}, {Header: "Description", Flex: true}}
)
func secretRow(item services.ActionSecret) []terminal.Cell {
return []terminal.Cell{{Text: item.Name}, {Time: terminal.ParseTime(item.CreatedAt)}, {Text: item.Description}}
}
func variableRow(item services.ActionVariable) []terminal.Cell {
return []terminal.Cell{{Text: item.Name}, {Text: item.Value}, {Text: item.Description}}
}
func (a *application) actionValueSetCommand(secret bool, kind string) *cobra.Command {
var scope actionScopeFlags
var body, bodyFile, envFile, description string
example := `# set a repository variable
$ tea variable set ENVIRONMENT --body staging
# set an organization variable from a file
$ tea variable set CONFIG --org acme < config.json
# set the variables of a dotenv file
$ tea variable set --env-file .env`
if secret {
example = `# paste the value of a repository secret at a prompt
$ tea secret set DEPLOY_TOKEN
# set an organization secret from standard input
$ echo -n "$TOKEN" | tea secret set NPM_TOKEN --org acme
# set the secrets of a dotenv file
$ tea secret set --env-file .env`
}
cmd := &cobra.Command{
Use: "set [<name>]",
Short: "Create or update a " + kind,
Long: fmt.Sprintf(`Create or update a %[1]s.
The value comes from --body, --body-file or standard input when it is not a terminal (one
trailing line break is removed), or else from a prompt. --env-file sets every NAME=value of a
dotenv file instead.`, kind),
Example: example,
Args: usageArgs(cobra.MaximumNArgs(1)),
RunE: func(cmd *cobra.Command, args []string) error {
if (len(args) == 1) == (envFile != "") {
return clierr.Wrap(clierr.Usage, &argumentError{problem: "pass a " + kind + " name or --env-file", cmd: cmd})
}
if envFile != "" && (cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file")) {
return clierr.Wrap(clierr.Usage, errors.New("--env-file cannot be combined with --body or --body-file"))
}
var entries []envEntry
if envFile != "" {
data, err := readFileOrStdin(a.deps.Streams.In, envFile)
if err != nil {
return fmt.Errorf("read --env-file: %w", err)
}
if entries, err = parseEnvFile(data); err != nil {
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s: %w", envFile, err))
}
} else {
value, err := a.actionValue(cmd, secret, args[0], body, bodyFile)
if err != nil {
return err
}
entries = []envEntry{{args[0], value}}
}
for _, entry := range entries {
if err := checkActionValueName(entry.name); err != nil {
return err
}
if entry.value == "" {
return clierr.Wrap(clierr.Usage, fmt.Errorf("the value of %s is empty", entry.name))
}
if !secret && len(entry.value) > 65536 {
return clierr.Wrap(clierr.Usage, fmt.Errorf("the value of %s exceeds 64 KiB", entry.name))
}
}
selected, t, err := scope.resolve(a, cmd)
if err != nil {
return err
}
a.accountNote(cmd, t, "for a "+kind+" of your account, use --account")
given := map[string]string{}
for _, entry := range entries {
given[services.ActionValueName(entry.name)] = entry.name
}
label := func(entry envEntry) string { return entry.name }
report := func(name string) string {
text := fmt.Sprintf("Set Actions %s %s for %s", kind, name, selected)
if given[name] != name {
text += " (names are upper-cased)"
}
return text
}
if secret {
service := services.ActionSecrets{Client: t.client, Scope: selected}
return batchDone(a, cmd, entries, label, func(entry envEntry) (services.ActionSecret, error) {
return service.Set(cmd.Context(), entry.name, entry.value, description)
}, func(item services.ActionSecret) string { return report(item.Name) })
}
service := services.ActionVariables{Client: t.client, Scope: selected}
return batchDone(a, cmd, entries, label, func(entry envEntry) (services.ActionVariable, error) {
return service.Set(cmd.Context(), entry.name, entry.value, description)
}, func(item services.ActionVariable) string { return report(item.Name) })
},
}
cmd.Flags().StringVarP(&body, "body", "b", "", "The value")
cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read the value from a file, or - for standard input")
cmd.Flags().StringVarP(&envFile, "env-file", "f", "", "Set every NAME=value of a dotenv file, or - for standard input")
cmd.Flags().StringVarP(&description, "description", "d", "", "Description")
flagAliases(cmd, map[string]string{"value": "body", "value-file": "body-file"})
scope.register(a, cmd, kind, true)
cmd.ValidArgsFunction = a.completeActionValues(secret, &scope)
return cmd
}
// actionValue returns the value to set: --body, --body-file, standard input when it is not a
// terminal, or else what the user types at a prompt.
func (a *application) actionValue(cmd *cobra.Command, secret bool, name, body, bodyFile string) (string, error) {
if cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") {
return a.readSecret(cmd, body, bodyFile, "body", "body-file")
}
missing := clierr.Wrap(clierr.Usage, errors.New("missing value: pass --body, --body-file or standard input"))
if !a.deps.Streams.InTTY {
data, err := io.ReadAll(a.deps.Streams.In)
if err != nil {
return "", fmt.Errorf("read standard input: %w", err)
}
if len(data) == 0 {
return "", missing
}
return strings.TrimSuffix(strings.TrimSuffix(string(data), "\n"), "\r"), nil
}
if !a.canPrompt() {
return "", missing
}
if secret {
return a.deps.Prompter.SecretInput(cmd.Context(), "Paste the value of "+strings.ToUpper(name)+":")
}
return a.deps.Prompter.Ask(cmd.Context(), "Value of "+strings.ToUpper(name)+":", nil)
}
// envEntry is one NAME=value line of a dotenv file.
type envEntry struct{ name, value string }
// parseEnvFile reads NAME=value lines, skipping blank lines and # comments. An optional export
// prefix is ignored; values may be quoted, and double-quoted ones understand \n.
func parseEnvFile(data []byte) ([]envEntry, error) {
var entries []envEntry
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(nil, 1<<20)
for number := 1; scanner.Scan(); number++ {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
name, value, ok := strings.Cut(strings.TrimPrefix(line, "export "), "=")
name, value = strings.TrimSpace(name), strings.TrimSpace(value)
if !ok || name == "" {
return nil, fmt.Errorf("line %d: use NAME=value", number)
}
switch {
case len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"':
value = strings.ReplaceAll(value[1:len(value)-1], `\n`, "\n")
case len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'':
value = value[1 : len(value)-1]
default:
value, _, _ = strings.Cut(value, " #")
}
entries = append(entries, envEntry{name, value})
}
if len(entries) == 0 {
return nil, errors.New("no NAME=value lines")
}
return entries, scanner.Err()
}
func (a *application) actionValueDeleteCommand(secret bool, kind string) *cobra.Command {
var scope actionScopeFlags
cmd := &cobra.Command{
Use: "delete <name>",
Short: "Delete a " + kind,
Example: fmt.Sprintf(`# delete a repository %[1]s
$ tea %[1]s delete DEPLOY_TOKEN
# delete an organization %[1]s without confirmation
$ tea %[1]s delete DEPLOY_TOKEN --org acme --yes`, kind),
Args: argsExact(1),
Annotations: map[string]string{"tea.confirm": "true", "tea.text": kind + " deletion does not return a resource"},
RunE: func(cmd *cobra.Command, args []string) error {
name := services.ActionValueName(args[0])
selected, t, err := scope.resolve(a, cmd)
if err != nil {
return err
}
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: fmt.Sprintf("Delete %s %s from %s?", kind, name, selected)}); err != nil {
return err
}
if secret {
err = services.ActionSecrets{Client: t.client, Scope: selected}.Delete(cmd.Context(), args[0])
} else {
err = services.ActionVariables{Client: t.client, Scope: selected}.Delete(cmd.Context(), args[0])
}
if err != nil {
return err
}
a.done(cmd, "Deleted Actions %s %s from %s", kind, name, selected)
return nil
},
}
scope.register(a, cmd, kind, true)
cmd.ValidArgsFunction = a.completeActionValues(secret, &scope)
return cmd
}
func (a *application) variableViewCommand() *cobra.Command {
var scope actionScopeFlags
cmd := &cobra.Command{
Use: "view <name>",
Short: "View a variable",
Example: `# view a variable with its description
$ tea variable view ENVIRONMENT`,
Args: argsExact(1),
RunE: func(cmd *cobra.Command, args []string) error {
selected, t, err := scope.resolve(a, cmd)
if err != nil {
return err
}
item, err := services.ActionVariables{Client: t.client, Scope: selected}.View(cmd.Context(), args[0])
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
d := a.display()
d.Markdown = false
view := terminal.View{Title: item.Name, Fields: []terminal.Field{{Label: "Scope", Value: selected.String()}, {Label: "Description", Value: item.Description}}, Body: item.Value}
return a.writeHuman(cmd, view.Render(d))
},
}
scope.register(a, cmd, "variable", true)
cmd.ValidArgsFunction = a.completeActionValues(false, &scope)
return cmd
}
func (a *application) variableGetCommand() *cobra.Command {
var scope actionScopeFlags
cmd := &cobra.Command{
Use: "get <name>",
Short: "Print the value of a variable",
Example: `# use the value of a variable in a script
$ region=$(tea variable get REGION --org acme)`,
Args: argsExact(1),
RunE: func(cmd *cobra.Command, args []string) error {
selected, t, err := scope.resolve(a, cmd)
if err != nil {
return err
}
item, err := services.ActionVariables{Client: t.client, Scope: selected}.View(cmd.Context(), args[0])
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
return writeTerminalSafe(cmd.OutOrStdout(), a.deps.Streams.OutTTY, strings.NewReader(item.Value+"\n"))
},
}
scope.register(a, cmd, "variable", true)
cmd.ValidArgsFunction = a.completeActionValues(false, &scope)
return cmd
}
// completeActionValues completes the secret or variable names of the scope the flags select.
func (a *application) completeActionValues(secret bool, scope *actionScopeFlags) cobra.CompletionFunc {
names := a.completeVariables(scope.scope)
if secret {
names = a.completeSecrets(scope.scope)
}
return firstArg(names)
}