fix(config): print only the value of config get and add display preferences

tea config get printed "key<TAB>value", so $(tea config get editor) was
not usable; it now prints the value alone, and --show-source appends the
source (D18). config get reads the instance's user, and instance settings
come from the selected instance (--host, TEA_HOST, default_host or the
sole instance). Unknown keys list the valid ones, and keys that need
--host or --user say so. config path no longer loads the settings, so it
works when hosts.yml is broken. config list is a table on a terminal.
config set confirms the change on a terminal and warns when default_host
has no account.

config.yml stores the hyperlinks (auto, always, never) and markdown
(auto, raw) preferences that tables and views now read, instead of the
TEA_HYPERLINKS and TEA_MARKDOWN variables. The environment table moves
out of tea config --help into tea help environment.

alias set refuses to replace an alias without --clobber, drops a leading
"tea", and explains how to quote an expansion with spaces or flags.
alias delete takes several names or --all, alias list is a table, and
an empty list says so on a terminal.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:49:52 +02:00
1 parent 0dfe7def9b
commit 5e8aa1011f
9 files changed
+687 -271

No files matched your search

+1 -7
View File
@@ -279,7 +279,7 @@ func (a *application) checkOutputFlags(cmd *cobra.Command) error {
// offline reports commands that must work without valid settings. // offline reports commands that must work without valid settings.
func offline(cmd *cobra.Command) bool { func offline(cmd *cobra.Command) bool {
if cmd.Annotations["tea.group"] == "true" { if cmd.Annotations["tea.group"] == "true" || cmd.Annotations["tea.offline"] == "true" {
return true return true
} }
switch cmd.Name() { switch cmd.Name() {
@@ -492,12 +492,6 @@ func commandIndex(root *cobra.Command, args []string) int {
return len(args) return len(args)
} }
// configKeys are the preferences, instance settings and account settings that config get reads.
var configKeys = []string{
"browser", "color", "default_host", "editor", "git_protocol", "insecure", "oauth_client_id", "pager", "prompt",
"remote", "ssh_agent", "ssh_certificate_principal", "ssh_host", "ssh_key", "ssh_key_fingerprint", "version",
}
func commandOutputFields(cmd *cobra.Command) []string { func commandOutputFields(cmd *cobra.Command) []string {
if cmd.Name() == "status" && cmd.Parent() != nil { if cmd.Name() == "status" && cmd.Parent() != nil {
if cmd.Parent().Name() == "pr" { if cmd.Parent().Name() == "pr" {
-10
View File
@@ -415,16 +415,11 @@ func walkCommands(cmd *cobra.Command, visit func(*cobra.Command)) {
// have yet to complete; see TestHelpTextsAreComplete. // have yet to complete; see TestHelpTextsAreComplete.
const ( const (
shortsToFix = ` shortsToFix = `
config get
config list
notification mark-read notification mark-read
notification mark-unread notification mark-unread
pr review cancel-request pr review cancel-request
` `
examplesToWrite = ` examplesToWrite = `
alias delete
alias list
alias set
api api
auth login auth login
auth logout auth logout
@@ -439,10 +434,6 @@ const (
branch rename branch rename
branch unprotect branch unprotect
branch view branch view
config get
config list
config path
config set
doctor doctor
issue attachment delete issue attachment delete
issue attachment download issue attachment download
@@ -557,7 +548,6 @@ const (
wiki view wiki view
` `
longsToWrite = ` longsToWrite = `
alias
auth auth
branch branch
issue issue
-9
View File
@@ -255,15 +255,6 @@ func (a *application) completeAliases(_ *cobra.Command, _ []string, toComplete s
return matching(describe(slices.Sorted(maps.Keys(aliases)), func(name string) (string, string) { return name, aliases[name] }), toComplete), cobra.ShellCompDirectiveNoFileComp return matching(describe(slices.Sorted(maps.Keys(aliases)), func(name string) (string, string) { return name, aliases[name] }), toComplete), cobra.ShellCompDirectiveNoFileComp
} }
// configValues lists the values of the settings that accept a fixed set.
var configValues = map[string][]string{
"git_protocol": {"https", "ssh"},
"prompt": {"enabled", "disabled"},
"color": {"auto", "always", "never"},
"insecure": {"true", "false"},
"ssh_agent": {"true", "false"},
}
// completeConfig completes a setting name, then its value: the fixed values, the configured instances // completeConfig completes a setting name, then its value: the fixed values, the configured instances
// for default_host, or the Git remotes for remote. // for default_host, or the Git remotes for remote.
func (a *application) completeConfig(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { func (a *application) completeConfig(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
+476 -234
View File
@@ -6,8 +6,10 @@ package app
import ( import (
"errors" "errors"
"fmt" "fmt"
"maps"
"path/filepath" "path/filepath"
"sort" "slices"
"strconv"
"strings" "strings"
"gitea.dev/tea/internal/aliases" "gitea.dev/tea/internal/aliases"
@@ -17,6 +19,26 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// Settings that tea config reads and writes, by where they are stored.
var (
preferenceKeys = []string{"browser", "color", "default_host", "editor", "git_protocol", "hyperlinks", "markdown", "pager", "prompt", "remote"}
instanceKeys = []string{"git_protocol", "insecure", "oauth_client_id", "ssh_host", "user"}
accountKeys = []string{"ssh_agent", "ssh_certificate_principal", "ssh_key", "ssh_key_fingerprint"}
// configKeys are every setting that config get reads, in alphabetical order.
configKeys = slices.Compact(slices.Sorted(slices.Values(slices.Concat(preferenceKeys, instanceKeys, accountKeys, []string{"version"}))))
)
// configValues lists the values of the settings that accept a fixed set.
var configValues = map[string][]string{
"git_protocol": {"https", "ssh"},
"prompt": {"enabled", "disabled"},
"color": {"auto", "always", "never"},
"hyperlinks": {"auto", "always", "never"},
"markdown": {"auto", "raw"},
"insecure": {"true", "false"},
"ssh_agent": {"true", "false"},
}
func (a *application) effectivePreferences(snapshot settings.Snapshot) (settings.Preferences, map[string]string, error) { func (a *application) effectivePreferences(snapshot settings.Snapshot) (settings.Preferences, map[string]string, error) {
preferences := snapshot.Preferences preferences := snapshot.Preferences
sources := map[string]string{} sources := map[string]string{}
@@ -42,7 +64,7 @@ func (a *application) effectivePreferences(snapshot settings.Snapshot) (settings
} }
} }
if host != "" { if host != "" {
normalized, err := settings.NormalizeHost(host) normalized, err := settings.ResolveHost(host, snapshot.Hosts.Hosts)
if err != nil { if err != nil {
return preferences, nil, clierr.Wrap(clierr.Usage, err) return preferences, nil, clierr.Wrap(clierr.Usage, err)
} }
@@ -85,269 +107,489 @@ func (a *application) effectivePreferences(snapshot settings.Snapshot) (settings
return preferences, sources, nil return preferences, sources, nil
} }
// configSetting is one effective setting and where its value comes from.
type configSetting struct {
Key string
Value any
Source string
}
// settingValues returns the effective preferences, the settings of the selected instance when it is
// configured, and with --user the settings of that account, each with its source.
func (a *application) settingValues(snapshot settings.Snapshot) (map[string]any, map[string]string, error) {
if a.options.user != "" && a.options.host == "" {
return nil, nil, clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user"))
}
preferences, sources, err := a.effectivePreferences(snapshot)
if err != nil {
return nil, nil, err
}
values := map[string]any{
"version": preferences.Version, "default_host": preferences.DefaultHost,
"git_protocol": preferences.GitProtocol, "editor": preferences.Editor,
"prompt": preferences.Prompt, "pager": preferences.Pager,
"browser": preferences.Browser, "color": preferences.Color,
"hyperlinks": preferences.Hyperlinks, "markdown": preferences.Markdown,
"remote": preferences.Remote,
}
host, configured := snapshot.Hosts.Hosts[preferences.DefaultHost]
if !configured {
if a.options.user != "" {
return nil, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("instance %s is not configured; run tea auth login --host %s", preferences.DefaultHost, preferences.DefaultHost))
}
return values, sources, nil
}
for key, value := range map[string]any{"user": host.User, "ssh_host": host.SSHHost, "insecure": host.Insecure, "oauth_client_id": host.OAuthClientID} {
values[key] = value
sources[key] = "hosts.yml " + preferences.DefaultHost
}
if a.options.user != "" {
user, exists := matchAccount(host.Users, a.options.user)
if !exists {
return nil, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("account %q is not logged in on %s (accounts: %s)", a.options.user, preferences.DefaultHost, accountNames(host.Users)))
}
account := host.Users[user]
for key, value := range map[string]any{"ssh_key": account.SSHKey, "ssh_agent": account.SSHAgent, "ssh_certificate_principal": account.SSHCertPrincipal, "ssh_key_fingerprint": account.SSHKeyFingerprint} {
values[key] = value
sources[key] = "hosts.yml " + preferences.DefaultHost + " user " + user
}
}
return values, sources, nil
}
// unknownSetting explains why key has no value: it names an account or instance setting that needs
// --host or --user, or no setting at all.
func unknownSetting(key string) error {
switch {
case slices.Contains(accountKeys, key):
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s is an account setting; select the account with --host URL --user NAME", key))
case slices.Contains(instanceKeys, key):
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s is an instance setting; select a configured instance with --host URL", key))
}
return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown setting %q; settings: %s", key, strings.Join(configKeys, ", ")))
}
func (a *application) configCommand() *cobra.Command { func (a *application) configCommand() *cobra.Command {
group := &cobra.Command{Use: "config", Short: "Manage preferences", Long: `Manage preferences. group := &cobra.Command{Use: "config", Short: "Manage settings", Long: `Manage tea's settings.
Settings live in config.yml and hosts.yml in the configuration directory Preferences live in config.yml and instance settings in hosts.yml, in the configuration
($TEA_CONFIG_DIR, or tea under the XDG config directory); tea config path prints both. directory ($TEA_CONFIG_DIR, or tea under the XDG config directory); tea config path prints
both files. Environment variables that override settings are listed in tea help environment.
config.yml preferences (tea config set <key> <value>): Preferences (tea config set <key> <value>):
default_host instance URL used when no --host, environment or repository selects one default_host instance used when no --host, environment variable or repository selects one
git_protocol https or ssh for clone and checkout remotes git_protocol https or ssh, for clone and checkout remotes
editor command used to edit bodies editor command that edits bodies
prompt enabled or disabled prompt enabled or disabled
pager command used to page human output pager command that pages long output
browser command used to open web pages; never disables opening browser command that opens web pages; never disables opening
color auto, always or never color auto, always or never
remote Git remote used for repository discovery hyperlinks auto, always or never: link the first column of lists to its web page
aliases command aliases; manage them with tea alias markdown auto or raw: render markdown bodies on a terminal, or print them as written
remote Git remote that selects the repository
aliases command shortcuts; manage them with tea alias
hosts.yml per-instance settings (tea config set --host <url> <key> <value>): Instance settings (tea config set --host <url> <key> <value>):
user active account, changed only by tea auth switch user active account; change it with tea auth switch
ssh_host SSH host[:port] when it differs from the web host ssh_host SSH host[:port] when it differs from the web host
insecure true skips TLS certificate verification insecure true skips TLS certificate verification
git_protocol https or ssh, overriding config.yml for this instance git_protocol https or ssh, overriding the preference for this instance
oauth_client_id OAuth application used by tea auth login oauth_client_id OAuth application used by tea auth login --oauth
Per-account SSH settings (tea config set --host <url> --user <name> <key> <value>): Account settings (tea config set --host <url> --user <name> <key> <value>):
ssh_key, ssh_agent, ssh_certificate_principal, ssh_key_fingerprint ssh_key, ssh_agent, ssh_certificate_principal, ssh_key_fingerprint`}
group.AddCommand(&cobra.Command{
Environment overrides: Use: "path", Short: "Print the paths of the settings files", Args: argsExact(0),
TEA_CONFIG_DIR configuration directory Annotations: map[string]string{"tea.text": "config path prints text", "tea.offline": "true"},
TEA_HOST, GITEA_INSTANCE_URL instance, overriding default_host Example: "$ tea config path\n# Settings of another configuration directory\n$ TEA_CONFIG_DIR=~/work/tea tea config path",
GITEA_TOKEN access token for that instance instead of stored credentials RunE: func(cmd *cobra.Command, _ []string) error {
GITEA_INSTANCE_INSECURE with GITEA_TOKEN, skip TLS verification when true _, err := fmt.Fprintf(cmd.OutOrStdout(), "config\t%s\nhosts\t%s\n", filepath.Join(a.deps.Settings.Dir, "config.yml"), filepath.Join(a.deps.Settings.Dir, "hosts.yml"))
GITEA_INSTANCE_SSH_HOST with GITEA_TOKEN, SSH host that identifies the instance's remotes return err
TEA_USER account on the selected instance },
TEA_REPO repository as owner/name or URL })
TEA_EDITOR editor, overriding config.yml var getSource bool
TEA_PAGER pager, overriding config.yml get := &cobra.Command{
TEA_COLOR color preference; NO_COLOR disables automatic color Use: "get <key>", Short: "Print the value of a setting", Args: argsExact(1), ValidArgsFunction: a.completeConfig,
VISUAL, EDITOR, PAGER, BROWSER editor (VISUAL first), pager and browser when config.yml sets none`} Example: `$ tea config get editor
group.AddCommand(&cobra.Command{Use: "path", Short: "Show configuration file paths", Annotations: textOutput("config path prints text"), Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { # Show where the value comes from
_, err := fmt.Fprintf(cmd.OutOrStdout(), "config\t%s\nhosts\t%s\n", filepath.Join(a.deps.Settings.Dir, "config.yml"), filepath.Join(a.deps.Settings.Dir, "hosts.yml")) $ tea config get default_host --show-source
return err # Read an instance or account setting
}}) $ tea config get ssh_host --host https://gitea.com
for _, name := range []string{"get", "list"} { $ tea config get ssh_key --host https://gitea.com --user alice`,
var source bool RunE: func(cmd *cobra.Command, args []string) error {
command := &cobra.Command{Use: name, Short: "Show effective preferences"}
command.Flags().BoolVar(&source, "show-source", false, "Show the source of each effective value")
if name == "get" {
command.Use = "get <key>"
command.Args = argsExact(1)
} else {
command.Args = argsExact(0)
}
command.RunE = func(cmd *cobra.Command, args []string) error {
if a.options.user != "" && a.options.host == "" {
return clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user"))
}
snapshot, err := a.deps.Settings.Load() snapshot, err := a.deps.Settings.Load()
if err != nil { if err != nil {
return err return err
} }
preferences, sources, err := a.effectivePreferences(snapshot) values, sources, err := a.settingValues(snapshot)
if err != nil { if err != nil {
return err return err
} }
if _, exists := snapshot.Hosts.Hosts[preferences.DefaultHost]; a.options.user != "" && !exists { value, ok := values[args[0]]
return clierr.Wrap(clierr.Usage, errors.New("instance is not configured; run tea auth login first")) if !ok {
} return unknownSetting(args[0])
values := map[string]any{
"version": preferences.Version, "default_host": preferences.DefaultHost,
"git_protocol": preferences.GitProtocol, "editor": preferences.Editor,
"prompt": preferences.Prompt, "pager": preferences.Pager,
"browser": preferences.Browser, "color": preferences.Color,
"remote": preferences.Remote,
}
if host, exists := snapshot.Hosts.Hosts[preferences.DefaultHost]; a.options.host != "" && exists {
for key, value := range map[string]any{"ssh_host": host.SSHHost, "insecure": host.Insecure, "oauth_client_id": host.OAuthClientID} {
values[key] = value
sources[key] = "hosts.yml " + preferences.DefaultHost
}
if a.options.user != "" {
user, exists := matchAccount(host.Users, a.options.user)
if !exists {
return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance"))
}
account := host.Users[user]
for key, value := range map[string]any{"ssh_key": account.SSHKey, "ssh_agent": account.SSHAgent, "ssh_certificate_principal": account.SSHCertPrincipal, "ssh_key_fingerprint": account.SSHKeyFingerprint} {
values[key] = value
sources[key] = "hosts.yml " + preferences.DefaultHost + " user " + user
}
}
}
if len(args) > 0 {
value, ok := values[args[0]]
if !ok {
return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown preference %q", args[0]))
}
values = map[string]any{args[0]: value}
} }
if a.options.json != "" { if a.options.json != "" {
if source { return a.writeSettingsJSON(cmd, map[string]any{args[0]: value}, sources, getSource)
results := map[string]any{}
for key, value := range values {
origin := sources[key]
if origin == "" {
origin = "default"
}
results[key] = struct {
Value any `json:"value"`
Source string `json:"source"`
}{value, origin}
}
return a.writeJSON(cmd, results)
}
return a.writeJSON(cmd, values)
} }
keys := make([]string, 0, len(values)) line := fmt.Sprint(value)
for key := range values { if getSource {
keys = append(keys, key) line += "\t" + settingSource(sources, args[0])
} }
sort.Strings(keys) _, err = fmt.Fprintln(cmd.OutOrStdout(), line)
for _, key := range keys { return err
suffix := "" },
if source { }
origin := "default" get.Flags().BoolVar(&getSource, "show-source", false, "Also print where the value comes from")
if sources[key] != "" { group.AddCommand(get)
origin = sources[key] var listSource bool
list := &cobra.Command{
Use: "list", Short: "List settings and their values", Args: argsExact(0),
Example: `$ tea config list
# Include the settings of an instance and where each value comes from
$ tea config list --host https://gitea.com --show-source`,
RunE: func(cmd *cobra.Command, _ []string) error {
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
values, sources, err := a.settingValues(snapshot)
if err != nil {
return err
}
if a.options.json != "" {
return a.writeSettingsJSON(cmd, values, sources, listSource)
}
items := make([]configSetting, 0, len(values))
for _, key := range slices.Sorted(maps.Keys(values)) {
items = append(items, configSetting{key, values[key], settingSource(sources, key)})
}
columns := []terminal.Column{{Header: "Key"}, {Header: "Value", Flex: true}}
if listSource {
columns = append(columns, terminal.Column{Header: "Source"})
}
return writeTable(a, cmd, items, listSpec[configSetting]{
Columns: columns,
Row: func(item configSetting) []terminal.Cell {
row := []terminal.Cell{{Text: item.Key}, {Text: fmt.Sprint(item.Value)}}
if listSource {
row = append(row, terminal.Cell{Text: item.Source, Muted: true})
} }
suffix = "\t" + origin return row
} },
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%v%s\n", key, values[key], suffix); err != nil { }, listPage{Number: 1})
},
}
list.Flags().BoolVar(&listSource, "show-source", false, "Show where each value comes from")
group.AddCommand(list)
group.AddCommand(&cobra.Command{
Use: "set <key> <value>", Short: "Change a setting", Args: argsExact(2), ValidArgsFunction: a.completeConfig,
Annotations: textOutput("config set does not return a resource"),
Example: `$ tea config set editor "code --wait"
$ tea config set git_protocol ssh
# Keep raw markdown and plain IDs on a terminal
$ tea config set markdown raw
$ tea config set hyperlinks never
# Change an instance or account setting
$ tea config set ssh_host git.example.com:2222 --host https://example.com
$ tea config set ssh_key ~/.ssh/id_ed25519 --host https://example.com --user alice`,
RunE: func(cmd *cobra.Command, args []string) error {
key, value := args[0], args[1]
if a.options.user != "" && a.options.host == "" {
return clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user"))
}
if a.options.host == "" {
value, err := a.setPreference(key, value)
if err != nil {
return err return err
} }
a.reportSet(cmd, key, value, "")
if key == "default_host" && value != "" {
a.warnWithoutAccount(cmd)
}
return nil
} }
return nil instance, err := a.setInstanceSetting(key, value)
}
group.AddCommand(command)
}
group.AddCommand(&cobra.Command{Use: "set <key> <value>", Short: "Set a preference, or an instance setting with --host", Annotations: textOutput("config set does not return a resource"), Args: argsExact(2), RunE: func(_ *cobra.Command, args []string) error {
if a.options.user != "" && a.options.host == "" {
return clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user"))
}
if a.options.host != "" {
instance, err := settings.NormalizeHost(a.options.host)
if err != nil { if err != nil {
return clierr.Wrap(clierr.Usage, err) return err
} }
return a.deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error { scope := " for " + instance
host, exists := s.Hosts.Hosts[instance] if a.options.user != "" {
if !exists { scope = " for " + a.options.user + " on " + instance
return clierr.Wrap(clierr.Usage, errors.New("instance is not configured; run tea auth login first"))
}
if a.options.user != "" {
user, exists := matchAccount(host.Users, a.options.user)
if !exists {
return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance"))
}
account := host.Users[user]
switch args[0] {
case "ssh_key":
account.SSHKey = args[1]
case "ssh_agent":
if args[1] != "true" && args[1] != "false" {
return clierr.Wrap(clierr.Usage, errors.New("ssh_agent must be true or false"))
}
account.SSHAgent = args[1] == "true"
case "ssh_certificate_principal":
account.SSHCertPrincipal = args[1]
case "ssh_key_fingerprint":
account.SSHKeyFingerprint = args[1]
default:
return clierr.Wrap(clierr.Usage, errors.New("supported account settings: ssh_key, ssh_agent, ssh_certificate_principal, ssh_key_fingerprint"))
}
host.Users[user] = account
s.Hosts.Hosts[instance] = host
return clierr.Wrap(clierr.Usage, s.Validate())
}
switch args[0] {
case "git_protocol":
host.GitProtocol = args[1]
case "ssh_host":
host.SSHHost = args[1]
case "insecure":
if args[1] != "true" && args[1] != "false" {
return clierr.Wrap(clierr.Usage, errors.New("insecure must be true or false"))
}
host.Insecure = args[1] == "true"
case "oauth_client_id":
host.OAuthClientID = args[1]
default:
return clierr.Wrap(clierr.Usage, fmt.Errorf("preference %q cannot be set per instance; supported keys: git_protocol, ssh_host, insecure, oauth_client_id", args[0]))
}
s.Hosts.Hosts[instance] = host
return clierr.Wrap(clierr.Usage, s.Validate())
})
}
return a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
p := &s.Preferences
switch args[0] {
case "default_host":
if args[1] == "" {
p.DefaultHost = ""
break
}
normalized, err := settings.NormalizeHost(args[1])
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
p.DefaultHost = normalized
case "git_protocol":
p.GitProtocol = args[1]
case "editor":
p.Editor = args[1]
case "prompt":
p.Prompt = args[1]
case "pager":
p.Pager = args[1]
case "browser":
p.Browser = args[1]
case "color":
p.Color = args[1]
case "remote":
p.Remote = args[1]
default:
return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown or immutable preference %q", args[0]))
} }
return clierr.Wrap(clierr.Usage, s.Validate()) a.reportSet(cmd, key, value, scope)
}) return nil
}}) },
})
return group return group
} }
func (a *application) aliasCommand() *cobra.Command { // settingSource names where the value of key comes from.
group := &cobra.Command{Use: "alias", Short: "Manage shortcuts to built-in commands"} func settingSource(sources map[string]string, key string) string {
group.AddCommand(&cobra.Command{Use: "set <name> <expansion>", Short: "Define a command alias", Annotations: textOutput("alias set does not return a resource"), Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error { if source := sources[key]; source != "" {
return a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error { return source
s.Preferences.Aliases[args[0]] = args[1] }
return clierr.Wrap(clierr.Usage, aliases.Check(args[0], s.Preferences.Aliases, builtins(cmd.Root()))) return "default"
}) }
}})
group.AddCommand(&cobra.Command{Use: "delete <name>", Short: "Remove a command alias", Annotations: textOutput("alias deletion does not return a resource"), Args: argsExact(1), RunE: func(cmd *cobra.Command, args []string) error { // writeSettingsJSON writes settings as a key-value object, or with source as objects of value and source.
return a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error { func (a *application) writeSettingsJSON(cmd *cobra.Command, values map[string]any, sources map[string]string, source bool) error {
if _, ok := s.Preferences.Aliases[args[0]]; !ok { if !source {
return clierr.Wrap(clierr.Usage, errors.New("alias does not exist")) return a.writeJSON(cmd, values)
}
type sourced struct {
Value any `json:"value"`
Source string `json:"source"`
}
results := map[string]sourced{}
for key, value := range values {
results[key] = sourced{value, settingSource(sources, key)}
}
return a.writeJSON(cmd, results)
}
// reportSet confirms a changed setting; scope names the instance or account it belongs to.
func (a *application) reportSet(cmd *cobra.Command, key, value, scope string) {
if value == "" {
a.done(cmd, "Cleared %s%s", key, scope)
return
}
a.done(cmd, "Set %s%s to %s", key, scope, value)
}
// warnWithoutAccount points out a default_host that has no account to act as yet.
func (a *application) warnWithoutAccount(cmd *cobra.Command) {
snapshot, err := a.deps.Settings.Load()
if host := snapshot.Preferences.DefaultHost; err == nil && len(snapshot.Hosts.Hosts[host].Users) == 0 {
a.warn(cmd, "No account on %s yet; log in with: tea auth login --host %s", host, host)
}
}
// setPreference changes a preference in config.yml and returns the stored value, such as a
// normalized default_host.
func (a *application) setPreference(key, value string) (string, error) {
return value, a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
p := &s.Preferences
switch key {
case "default_host":
if value != "" {
normalized, err := settings.ResolveHost(value, s.Hosts.Hosts)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
value = normalized
}
p.DefaultHost = value
case "git_protocol":
p.GitProtocol = value
case "editor":
p.Editor = value
case "prompt":
p.Prompt = value
case "pager":
p.Pager = value
case "browser":
p.Browser = value
case "color":
p.Color = value
case "hyperlinks":
p.Hyperlinks = value
case "markdown":
p.Markdown = value
case "remote":
p.Remote = value
case "user":
return clierr.Wrap(clierr.Usage, errors.New("the active account changes with tea auth switch"))
default:
if slices.Contains(instanceKeys, key) || slices.Contains(accountKeys, key) {
return unknownSetting(key)
}
return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown setting %q; preferences: %s", key, strings.Join(preferenceKeys, ", ")))
}
return clierr.Wrap(clierr.Usage, s.Validate())
})
}
// setInstanceSetting changes a setting of the --host instance in hosts.yml, or with --user one of its
// account's settings, and returns the instance.
func (a *application) setInstanceSetting(key, value string) (string, error) {
var instance string
return instance, a.deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error {
var err error
if instance, err = settings.ResolveHost(a.options.host, s.Hosts.Hosts); err != nil {
return clierr.Wrap(clierr.Usage, err)
}
host, exists := s.Hosts.Hosts[instance]
if !exists {
return clierr.Wrap(clierr.Usage, fmt.Errorf("instance %s is not configured; run tea auth login --host %s", instance, instance))
}
if a.options.user != "" {
user, exists := matchAccount(host.Users, a.options.user)
if !exists {
return clierr.Wrap(clierr.Usage, fmt.Errorf("account %q is not logged in on %s (accounts: %s)", a.options.user, instance, accountNames(host.Users)))
}
account := host.Users[user]
switch key {
case "ssh_key":
account.SSHKey = value
case "ssh_agent":
if value != "true" && value != "false" {
return clierr.Wrap(clierr.Usage, errors.New("ssh_agent must be true or false"))
}
account.SSHAgent = value == "true"
case "ssh_certificate_principal":
account.SSHCertPrincipal = value
case "ssh_key_fingerprint":
account.SSHKeyFingerprint = value
default:
return clierr.Wrap(clierr.Usage, fmt.Errorf("%q is not an account setting; account settings: %s", key, strings.Join(accountKeys, ", ")))
}
host.Users[user] = account
s.Hosts.Hosts[instance] = host
return clierr.Wrap(clierr.Usage, s.Validate())
}
switch key {
case "git_protocol":
host.GitProtocol = value
case "ssh_host":
host.SSHHost = value
case "insecure":
if value != "true" && value != "false" {
return clierr.Wrap(clierr.Usage, errors.New("insecure must be true or false"))
}
host.Insecure = value == "true"
case "oauth_client_id":
host.OAuthClientID = value
case "user":
return clierr.Wrap(clierr.Usage, errors.New("the active account changes with tea auth switch"))
default:
return clierr.Wrap(clierr.Usage, fmt.Errorf("%q is not an instance setting; instance settings: git_protocol, insecure, oauth_client_id, ssh_host", key))
}
s.Hosts.Hosts[instance] = host
return clierr.Wrap(clierr.Usage, s.Validate())
})
}
func (a *application) aliasCommand() *cobra.Command {
group := &cobra.Command{Use: "alias", Short: "Create shortcuts for tea commands", Long: `Create shortcuts for tea commands.
An alias expands to a tea command with its arguments before tea runs it: after
tea alias set il "issue list --label bug", tea il --state all runs
tea issue list --label bug --state all. $1, $2 and so on in the expansion take the
arguments in that position; other arguments follow the expansion. Aliases never run a
shell, and built-in commands always win over an alias of the same name.`}
var clobber bool
set := &cobra.Command{
Use: "set <name> <expansion>", Short: "Create a shortcut for a tea command",
Annotations: textOutput("alias set does not return a resource"),
Args: func(cmd *cobra.Command, args []string) error {
if len(args) > 2 {
return clierr.Wrap(clierr.Usage, fmt.Errorf("the expansion must be one argument; quote it: tea alias set %s %q", args[0], strings.Join(args[1:], " ")))
}
return argsExact(2)(cmd, args)
},
Example: `# tea il runs tea issue list --label bug
$ tea alias set il "issue list --label bug"
# $1 takes the first argument: tea review 13 runs tea pr review 13 --approve
$ tea alias set review 'pr review $1 --approve'
# Replace an existing alias
$ tea alias set il "issue list --assignee @me" --clobber`,
RunE: func(cmd *cobra.Command, args []string) error {
name, expansion := args[0], strings.TrimPrefix(strings.TrimSpace(args[1]), "tea ")
var previous string
err := a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
previous = s.Preferences.Aliases[name]
if previous != "" && !clobber {
return fmt.Errorf("alias %s already expands to %q; replace it with --clobber", name, previous)
}
s.Preferences.Aliases[name] = expansion
return clierr.Wrap(clierr.Usage, aliases.Check(name, s.Preferences.Aliases, builtins(cmd.Root())))
})
if err != nil {
return err
}
if previous != "" {
a.done(cmd, "Changed alias %s from %q to %q", name, previous, expansion)
} else {
a.done(cmd, "Added alias %s for %q", name, expansion)
} }
delete(s.Preferences.Aliases, args[0])
return nil return nil
}) },
}}) }
group.AddCommand(&cobra.Command{Use: "list", Short: "List command aliases", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { set.Flags().BoolVar(&clobber, "clobber", false, "Replace an existing alias of the same name")
s, err := a.deps.Settings.Load() set.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
if err != nil { return clierr.Wrap(clierr.Usage, fmt.Errorf("%w; quote an expansion with flags: tea alias set il \"issue list --state all\"", err))
return err })
} group.AddCommand(set)
if a.options.json != "" { var all bool
return a.writeJSON(cmd, s.Preferences.Aliases) remove := &cobra.Command{
} Use: "delete [<name>...]", Short: "Delete shortcuts", ValidArgsFunction: a.completeAliases,
names := make([]string, 0, len(s.Preferences.Aliases)) Annotations: textOutput("alias deletion does not return a resource"),
for name := range s.Preferences.Aliases { Args: func(_ *cobra.Command, args []string) error {
names = append(names, name) if len(args) == 0 && !all {
} return clierr.Wrap(clierr.Usage, errors.New("name the aliases to delete, or pass --all"))
sort.Strings(names) }
return a.writeHuman(cmd, table(names, func(name string) []terminal.Cell { return terminal.Row(name, s.Preferences.Aliases[name]) }, a.colorEnabled())) if len(args) > 0 && all {
}}) return clierr.Wrap(clierr.Usage, errors.New("alias names and --all are mutually exclusive"))
}
return nil
},
Example: "$ tea alias delete il\n$ tea alias delete il review\n$ tea alias delete --all",
RunE: func(cmd *cobra.Command, args []string) error {
deleted := map[string]string{}
err := a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
names := args
if all {
names = slices.Collect(maps.Keys(s.Preferences.Aliases))
}
var missing []string
for _, name := range names {
expansion, ok := s.Preferences.Aliases[name]
if !ok {
missing = append(missing, strconv.Quote(name))
continue
}
deleted[name] = expansion
delete(s.Preferences.Aliases, name)
}
if len(missing) > 0 {
return clierr.Wrap(clierr.Usage, fmt.Errorf("no alias named %s; tea alias list shows them", strings.Join(missing, ", ")))
}
return nil
})
if err != nil {
return err
}
if len(deleted) == 0 {
a.warn(cmd, "No aliases to delete")
}
for _, name := range slices.Sorted(maps.Keys(deleted)) {
a.done(cmd, "Deleted alias %s (%s)", name, deleted[name])
}
return nil
},
}
remove.Flags().BoolVar(&all, "all", false, "Delete every alias")
group.AddCommand(remove)
group.AddCommand(&cobra.Command{
Use: "list", Short: "List shortcuts", Args: argsExact(0),
Example: "$ tea alias list\n# Print the expansion of one alias\n$ tea alias list --jq .il",
RunE: func(cmd *cobra.Command, _ []string) error {
s, err := a.deps.Settings.Load()
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, s.Preferences.Aliases)
}
return writeTable(a, cmd, slices.Sorted(maps.Keys(s.Preferences.Aliases)), listSpec[string]{
Columns: []terminal.Column{{Header: "Name"}, {Header: "Expansion", Flex: true}},
Row: func(name string) []terminal.Cell {
return []terminal.Cell{{Text: name, Bold: true}, {Text: s.Preferences.Aliases[name]}}
},
Empty: "no aliases configured; create one with: tea alias set <name> <expansion>",
}, listPage{Number: 1})
},
})
return group return group
} }
+164 -3
View File
@@ -7,7 +7,9 @@ import (
"context" "context"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time"
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/settings" "gitea.dev/tea/internal/settings"
@@ -94,11 +96,170 @@ func TestConfigHelpDocumentsSettingsAndEnvironment(t *testing.T) {
deps, out, _ := testDeps(t) deps, out, _ := testDeps(t)
require.NoError(t, Execute(context.Background(), deps, []string{"config", "--help"})) require.NoError(t, Execute(context.Background(), deps, []string{"config", "--help"}))
for _, name := range []string{ for _, name := range []string{
"default_host", "git_protocol", "editor", "prompt", "pager", "browser", "color", "remote", "aliases", "default_host", "git_protocol", "editor", "prompt", "pager", "browser", "color", "hyperlinks", "markdown", "remote", "aliases",
"user", "ssh_host", "insecure", "oauth_client_id", "ssh_key", "ssh_agent", "ssh_certificate_principal", "ssh_key_fingerprint", "user", "ssh_host", "insecure", "oauth_client_id", "ssh_key", "ssh_agent", "ssh_certificate_principal", "ssh_key_fingerprint",
"TEA_CONFIG_DIR", "TEA_HOST", "GITEA_INSTANCE_URL", "GITEA_TOKEN", "TEA_USER", "TEA_REPO", "TEA_EDITOR", "VISUAL", "EDITOR", "tea help environment",
"TEA_PAGER", "PAGER", "TEA_COLOR", "NO_COLOR", "BROWSER",
} { } {
require.Contains(t, out.String(), name) require.Contains(t, out.String(), name)
} }
require.NotContains(t, out.String(), "GITEA_TOKEN", "the environment table lives in tea help environment")
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"help", "environment"}))
for _, name := range []string{"TEA_CONFIG_DIR", "TEA_HOST", "GITEA_INSTANCE_URL", "GITEA_TOKEN", "TEA_USER", "TEA_REPO", "TEA_EDITOR", "VISUAL", "EDITOR", "TEA_PAGER", "PAGER", "TEA_COLOR", "NO_COLOR", "BROWSER"} {
require.Contains(t, out.String(), name)
}
}
func TestConfigGetPrintsOnlyTheValue(t *testing.T) {
deps, out, _ := testDeps(t)
configureHost(t, deps, "http://localhost:3001", settings.Host{User: "alice", Users: tokenAccounts(settings.StoreFile, "alice", "bob")})
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", "editor", "code --wait"}))
for _, tc := range []struct {
args []string
want string
}{
{[]string{"config", "get", "editor"}, "code --wait\n"},
{[]string{"config", "get", "editor", "--show-source"}, "code --wait\tconfig.yml\n"},
{[]string{"config", "get", "pager"}, "\n"},
{[]string{"config", "get", "hyperlinks", "--show-source"}, "auto\tdefault\n"},
{[]string{"config", "get", "user", "--host", "http://localhost:3001"}, "alice\n"},
{[]string{"config", "get", "user", "--host", "localhost:3001", "--show-source"}, "alice\thosts.yml http://localhost:3001\n"},
{[]string{"config", "get", "user"}, "alice\n"},
{[]string{"config", "get", "insecure"}, "false\n"},
} {
t.Run(strings.Join(tc.args[2:], " "), func(t *testing.T) {
out.Reset()
require.NoError(t, Execute(context.Background(), deps, tc.args))
require.Equal(t, tc.want, out.String())
})
}
}
func TestConfigNamesValidSettings(t *testing.T) {
deps, _, _ := testDeps(t)
for _, tc := range []struct {
args []string
want string
}{
{[]string{"config", "get", "nope"}, `unknown setting "nope"; settings: browser, color, default_host, editor, git_protocol, hyperlinks, insecure, markdown,`},
{[]string{"config", "get", "user"}, "user is an instance setting; select a configured instance with --host URL"},
{[]string{"config", "get", "ssh_key"}, "ssh_key is an account setting; select the account with --host URL --user NAME"},
{[]string{"config", "set", "nope", "1"}, `unknown setting "nope"; preferences: browser, color, default_host,`},
{[]string{"config", "set", "user", "bob"}, "the active account changes with tea auth switch"},
{[]string{"config", "set", "hyperlinks", "off"}, "hyperlinks must be auto, always, or never"},
{[]string{"config", "set", "markdown", "never"}, "markdown must be auto or raw"},
} {
t.Run(strings.Join(tc.args[1:], " "), func(t *testing.T) {
err := Execute(context.Background(), deps, tc.args)
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, tc.want)
})
}
}
func TestConfigPathWorksWithBrokenSettings(t *testing.T) {
deps, out, _ := testDeps(t)
require.NoError(t, os.WriteFile(filepath.Join(deps.Settings.Dir, "hosts.yml"), []byte("version: '1'\nhosts: [\n"), 0o600))
require.Error(t, Execute(context.Background(), deps, []string{"config", "list"}))
require.NoError(t, Execute(context.Background(), deps, []string{"config", "path"}))
require.Equal(t, "config\t"+filepath.Join(deps.Settings.Dir, "config.yml")+"\nhosts\t"+filepath.Join(deps.Settings.Dir, "hosts.yml")+"\n", out.String())
}
func TestConfigSetReportsChangesOnATerminal(t *testing.T) {
deps, out, diagnostics := testDeps(t)
deps.Streams.OutTTY = true
configureHost(t, deps, "https://example.org", settings.Host{User: "alice", Users: tokenAccounts(settings.StoreFile, "alice")})
for _, tc := range []struct {
args []string
want string
}{
{[]string{"config", "set", "markdown", "raw"}, "✓ Set markdown to raw\n"},
{[]string{"config", "set", "editor", ""}, "✓ Cleared editor\n"},
{[]string{"config", "set", "ssh_host", "git.example.org:2222", "--host", "example.org"}, "✓ Set ssh_host for https://example.org to git.example.org:2222\n"},
{[]string{"config", "set", "default_host", "https://example.org"}, "✓ Set default_host to https://example.org\n"},
{[]string{"config", "set", "default_host", "localhost:3002"}, "✓ Set default_host to https://localhost:3002\n! No account on https://localhost:3002 yet; log in with: tea auth login --host https://localhost:3002\n"},
} {
t.Run(strings.Join(tc.args[2:], " "), func(t *testing.T) {
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, tc.args))
require.Equal(t, tc.want, withoutANSI(diagnostics.String()))
require.Empty(t, out.String())
})
}
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
require.Equal(t, "raw", snapshot.Preferences.Markdown)
require.Equal(t, "git.example.org:2222", snapshot.Hosts.Hosts["https://example.org"].SSHHost)
}
func TestDisplayFollowsHyperlinkAndMarkdownPreferences(t *testing.T) {
for _, tc := range []struct {
config []string
links, rich bool
}{
{nil, true, true},
{[]string{"hyperlinks", "never"}, false, true},
{[]string{"markdown", "raw"}, true, false},
} {
deps, _, _ := testDeps(t)
deps.Streams.OutTTY, deps.Now = true, time.Now
for i := 0; i < len(tc.config); i += 2 {
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", tc.config[i], tc.config[i+1]}))
}
d := (&application{deps: deps}).display()
require.Equal(t, tc.links, d.Links, tc.config)
require.Equal(t, tc.rich, d.Markdown, tc.config)
}
}
func TestAliasSetGuardsExistingAliasesAndQuoting(t *testing.T) {
deps, _, diagnostics := testDeps(t)
deps.Streams.OutTTY = true
run := func(args ...string) error {
return Execute(context.Background(), deps, append([]string{"alias", "set"}, args...))
}
require.NoError(t, run("il", "tea issue list --label bug"))
require.Equal(t, "✓ Added alias il for \"issue list --label bug\"\n", withoutANSI(diagnostics.String()))
err := run("il", "issue list")
require.ErrorContains(t, err, `alias il already expands to "issue list --label bug"; replace it with --clobber`)
diagnostics.Reset()
require.NoError(t, run("il", "issue list --state all", "--clobber"))
require.Equal(t, "✓ Changed alias il from \"issue list --label bug\" to \"issue list --state all\"\n", withoutANSI(diagnostics.String()))
err = run("ls", "issue", "list")
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.EqualError(t, err, `the expansion must be one argument; quote it: tea alias set ls "issue list"`)
err = run("il", "issue", "list", "--state", "all")
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, `unknown flag: --state; quote an expansion with flags: tea alias set il "issue list --state all"`)
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
require.Equal(t, map[string]string{"il": "issue list --state all"}, snapshot.Preferences.Aliases)
}
func TestAliasDeleteAndList(t *testing.T) {
deps, out, diagnostics := testDeps(t)
deps.Streams.OutTTY = true
run := func(args ...string) error {
return Execute(context.Background(), deps, append([]string{"alias"}, args...))
}
require.NoError(t, run("list"))
require.Empty(t, out.String())
require.Equal(t, "no aliases configured; create one with: tea alias set <name> <expansion>\n", diagnostics.String())
for _, name := range []string{"a", "b", "c"} {
require.NoError(t, run("set", name, "issue list"))
}
diagnostics.Reset()
require.NoError(t, run("list"))
require.Equal(t, "NAME EXPANSION\na issue list\nb issue list\nc issue list\n", withoutANSI(out.String()))
err := run("delete", "a", "nope", "zz")
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.EqualError(t, err, `no alias named "nope", "zz"; tea alias list shows them`)
require.NoError(t, run("delete", "a", "b"))
require.Equal(t, "✓ Deleted alias a (issue list)\n✓ Deleted alias b (issue list)\n", withoutANSI(diagnostics.String()))
require.Equal(t, clierr.Usage, clierr.ExitCode(run("delete")))
require.Equal(t, clierr.Usage, clierr.ExitCode(run("delete", "c", "--all")))
require.NoError(t, run("delete", "--all"))
snapshot, err := deps.Settings.Load()
require.NoError(t, err)
require.Empty(t, snapshot.Preferences.Aliases)
} }
+3 -4
View File
@@ -30,8 +30,7 @@ func (a *application) colorEnabled() bool {
return terminal.ColorEnabled(preferences.Color, a.deps.Streams.OutTTY, a.deps.Getenv) return terminal.ColorEnabled(preferences.Color, a.deps.Streams.OutTTY, a.deps.Getenv)
} }
// display describes stdout for tables and views. The hyperlinks (auto, always, never) and markdown // display describes stdout for tables and views under the color, hyperlinks and markdown preferences.
// (auto, raw) preferences are read from TEA_HYPERLINKS and TEA_MARKDOWN until config.yml stores them.
func (a *application) display() terminal.Display { func (a *application) display() terminal.Display {
preferences, _ := a.preferences() preferences, _ := a.preferences()
streams := a.deps.Streams streams := a.deps.Streams
@@ -39,8 +38,8 @@ func (a *application) display() terminal.Display {
TTY: streams.OutTTY, TTY: streams.OutTTY,
Color: terminal.ColorEnabled(preferences.Color, streams.OutTTY, a.deps.Getenv), Color: terminal.ColorEnabled(preferences.Color, streams.OutTTY, a.deps.Getenv),
TrueColor: terminal.TrueColor(a.deps.Getenv), TrueColor: terminal.TrueColor(a.deps.Getenv),
Links: terminal.HyperlinksEnabled(a.deps.Getenv("TEA_HYPERLINKS"), streams.OutTTY, a.deps.Getenv), Links: terminal.HyperlinksEnabled(preferences.Hyperlinks, streams.OutTTY, a.deps.Getenv),
Markdown: terminal.MarkdownEnabled(a.deps.Getenv("TEA_MARKDOWN"), streams.OutTTY), Markdown: terminal.MarkdownEnabled(preferences.Markdown, streams.OutTTY),
Width: streams.Width, Width: streams.Width,
Now: a.deps.Now(), Now: a.deps.Now(),
} }
+8 -2
View File
@@ -15,6 +15,7 @@ import (
"testing" "testing"
"time" "time"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/terminal"
"github.com/mattn/go-runewidth" "github.com/mattn/go-runewidth"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -51,6 +52,7 @@ func pagedDeps(t *testing.T, serverURL string, env map[string]string, config ...
for i := 0; i < len(config); i += 2 { for i := 0; i < len(config); i += 2 {
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", config[i], config[i+1]})) require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", config[i], config[i+1]}))
} }
diagnostics.Reset()
return deps, out, diagnostics, pager return deps, out, diagnostics, pager
} }
@@ -174,7 +176,7 @@ func TestMultiItemHumanOutputPagesOnce(t *testing.T) {
{[]string{"issue", "comment", "list", "5"}, "1 alice\none\n\n2 bob\ntwo\n\n"}, {[]string{"issue", "comment", "list", "5"}, "1 alice\none\n\n2 bob\ntwo\n\n"},
} { } {
t.Run(tc.args[0]+" "+tc.args[1], func(t *testing.T) { t.Run(tc.args[0]+" "+tc.args[1], func(t *testing.T) {
deps, out, _, pager := pagedDeps(t, server.URL, map[string]string{"TEA_HYPERLINKS": "never"}, "pager", "fake-pager", "color", "never") deps, out, _, pager := pagedDeps(t, server.URL, nil, "pager", "fake-pager", "color", "never", "hyperlinks", "never")
require.NoError(t, Execute(context.Background(), deps, append(tc.args, "-R", "alice/project", "--yes"))) require.NoError(t, Execute(context.Background(), deps, append(tc.args, "-R", "alice/project", "--yes")))
require.Equal(t, 1, pager.calls) require.Equal(t, 1, pager.calls)
require.Equal(t, tc.want, pager.input) require.Equal(t, tc.want, pager.input)
@@ -341,7 +343,11 @@ func TestWriteView(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
deps, out, _ := testDeps(t) deps, out, _ := testDeps(t)
deps.Streams.OutTTY = tc.tty deps.Streams.OutTTY = tc.tty
deps.Getenv = envFunc(map[string]string{"NO_COLOR": "1", "TEA_MARKDOWN": "raw", "TEA_HYPERLINKS": "never"}) deps.Getenv = envFunc(map[string]string{"NO_COLOR": "1"})
require.NoError(t, deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error {
s.Preferences.Markdown, s.Preferences.Hyperlinks = "raw", "never"
return nil
}))
a, cmd := outputApplication(deps, tc.json) a, cmd := outputApplication(deps, tc.json)
require.NoError(t, writeView(a, cmd, item, view)) require.NoError(t, writeView(a, cmd, item, view))
require.Equal(t, tc.want, out.String()) require.Equal(t, tc.want, out.String())
+15 -1
View File
@@ -52,6 +52,8 @@ type Preferences struct {
Pager string `yaml:"pager,omitempty" json:"pager"` Pager string `yaml:"pager,omitempty" json:"pager"`
Browser string `yaml:"browser,omitempty" json:"browser"` Browser string `yaml:"browser,omitempty" json:"browser"`
Color string `yaml:"color,omitempty" json:"color"` Color string `yaml:"color,omitempty" json:"color"`
Hyperlinks string `yaml:"hyperlinks,omitempty" json:"hyperlinks"`
Markdown string `yaml:"markdown,omitempty" json:"markdown"`
Remote string `yaml:"remote,omitempty" json:"remote"` Remote string `yaml:"remote,omitempty" json:"remote"`
Aliases map[string]string `yaml:"aliases,omitempty" json:"aliases"` Aliases map[string]string `yaml:"aliases,omitempty" json:"aliases"`
} }
@@ -114,7 +116,7 @@ func New(dir string) *Store {
// Defaults returns a fresh preferences value with built-in settings. // Defaults returns a fresh preferences value with built-in settings.
func Defaults() Preferences { func Defaults() Preferences {
return Preferences{Version: Version, GitProtocol: "https", Prompt: "enabled", Color: "auto", Aliases: map[string]string{}} return Preferences{Version: Version, GitProtocol: "https", Prompt: "enabled", Color: "auto", Hyperlinks: "auto", Markdown: "auto", Aliases: map[string]string{}}
} }
// stored omits built-in defaults so that only explicit settings are persisted. // stored omits built-in defaults so that only explicit settings are persisted.
@@ -129,6 +131,12 @@ func (p Preferences) stored() Preferences {
if p.Color == defaults.Color { if p.Color == defaults.Color {
p.Color = "" p.Color = ""
} }
if p.Hyperlinks == defaults.Hyperlinks {
p.Hyperlinks = ""
}
if p.Markdown == defaults.Markdown {
p.Markdown = ""
}
return p return p
} }
@@ -247,6 +255,12 @@ func (s Snapshot) Validate() error {
if p.Color != "auto" && p.Color != "always" && p.Color != "never" { if p.Color != "auto" && p.Color != "always" && p.Color != "never" {
return errors.New("color must be auto, always, or never") return errors.New("color must be auto, always, or never")
} }
if p.Hyperlinks != "auto" && p.Hyperlinks != "always" && p.Hyperlinks != "never" {
return errors.New("hyperlinks must be auto, always, or never")
}
if p.Markdown != "auto" && p.Markdown != "raw" {
return errors.New("markdown must be auto or raw")
}
if p.DefaultHost != "" { if p.DefaultHost != "" {
normalized, err := NormalizeHost(p.DefaultHost) normalized, err := NormalizeHost(p.DefaultHost)
if err != nil || normalized != p.DefaultHost { if err != nil || normalized != p.DefaultHost {
+20 -1
View File
@@ -120,7 +120,7 @@ func TestUpdatePersistsOnlyExplicitPreferences(t *testing.T) {
snapshot, err := s.Load() snapshot, err := s.Load()
require.NoError(t, err) require.NoError(t, err)
require.True(t, snapshot.Configured["editor"]) require.True(t, snapshot.Configured["editor"])
for _, key := range []string{"git_protocol", "prompt", "color", "aliases"} { for _, key := range []string{"git_protocol", "prompt", "color", "hyperlinks", "markdown", "aliases"} {
require.False(t, snapshot.Configured[key], key) require.False(t, snapshot.Configured[key], key)
} }
require.Equal(t, Defaults().GitProtocol, snapshot.Preferences.GitProtocol) require.Equal(t, Defaults().GitProtocol, snapshot.Preferences.GitProtocol)
@@ -130,6 +130,25 @@ func TestUpdatePersistsOnlyExplicitPreferences(t *testing.T) {
require.Equal(t, "version: \"1\"\neditor: vi\ncolor: never\n", string(data)) require.Equal(t, "version: \"1\"\neditor: vi\ncolor: never\n", string(data))
} }
func TestDisplayPreferencesAcceptOnlyTheirValues(t *testing.T) {
for _, tc := range []struct {
config string
valid bool
}{
{"hyperlinks: never\n", true},
{"hyperlinks: always\nmarkdown: raw\n", true},
{"hyperlinks: off\n", false},
{"markdown: never\n", false},
} {
t.Run(tc.config, func(t *testing.T) {
s := New(t.TempDir())
require.NoError(t, os.WriteFile(filepath.Join(s.Dir, "config.yml"), []byte("version: '1'\n"+tc.config), 0o600))
_, err := s.Load()
require.Equal(t, tc.valid, err == nil, "%v", err)
})
}
}
func TestTokenlessAccounts(t *testing.T) { func TestTokenlessAccounts(t *testing.T) {
for method, want := range map[string]bool{AuthToken: false, AuthOAuth: false, AuthSSH: true, AuthHTTPSign: true} { for method, want := range map[string]bool{AuthToken: false, AuthOAuth: false, AuthSSH: true, AuthHTTPSign: true} {
require.Equal(t, want, Account{AuthMethod: method}.Tokenless(), method) require.Equal(t, want, Account{AuthMethod: method}.Tokenless(), method)