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
+635 -219

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) {
+424 -182
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,90 +107,46 @@ func (a *application) effectivePreferences(snapshot settings.Snapshot) (settings
return preferences, sources, nil return preferences, sources, nil
} }
func (a *application) configCommand() *cobra.Command { // configSetting is one effective setting and where its value comes from.
group := &cobra.Command{Use: "config", Short: "Manage preferences", Long: `Manage preferences. type configSetting struct {
Key string
Settings live in config.yml and hosts.yml in the configuration directory Value any
($TEA_CONFIG_DIR, or tea under the XDG config directory); tea config path prints both. Source string
config.yml preferences (tea config set <key> <value>):
default_host instance URL used when no --host, environment or repository selects one
git_protocol https or ssh for clone and checkout remotes
editor command used to edit bodies
prompt enabled or disabled
pager command used to page human output
browser command used to open web pages; never disables opening
color auto, always or never
remote Git remote used for repository discovery
aliases command aliases; manage them with tea alias
hosts.yml per-instance settings (tea config set --host <url> <key> <value>):
user active account, changed only by tea auth switch
ssh_host SSH host[:port] when it differs from the web host
insecure true skips TLS certificate verification
git_protocol https or ssh, overriding config.yml for this instance
oauth_client_id OAuth application used by tea auth login
Per-account SSH settings (tea config set --host <url> --user <name> <key> <value>):
ssh_key, ssh_agent, ssh_certificate_principal, ssh_key_fingerprint
Environment overrides:
TEA_CONFIG_DIR configuration directory
TEA_HOST, GITEA_INSTANCE_URL instance, overriding default_host
GITEA_TOKEN access token for that instance instead of stored credentials
GITEA_INSTANCE_INSECURE with GITEA_TOKEN, skip TLS verification when true
GITEA_INSTANCE_SSH_HOST with GITEA_TOKEN, SSH host that identifies the instance's remotes
TEA_USER account on the selected instance
TEA_REPO repository as owner/name or URL
TEA_EDITOR editor, overriding config.yml
TEA_PAGER pager, overriding config.yml
TEA_COLOR color preference; NO_COLOR disables automatic color
VISUAL, EDITOR, PAGER, BROWSER editor (VISUAL first), pager and browser when config.yml sets none`}
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 {
_, 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"))
return err
}})
for _, name := range []string{"get", "list"} {
var source bool
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 {
// 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 == "" { if a.options.user != "" && a.options.host == "" {
return clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user")) return nil, nil, clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user"))
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
} }
preferences, sources, err := a.effectivePreferences(snapshot) preferences, sources, err := a.effectivePreferences(snapshot)
if err != nil { if err != nil {
return err return nil, nil, err
}
if _, exists := snapshot.Hosts.Hosts[preferences.DefaultHost]; a.options.user != "" && !exists {
return clierr.Wrap(clierr.Usage, errors.New("instance is not configured; run tea auth login first"))
} }
values := map[string]any{ values := map[string]any{
"version": preferences.Version, "default_host": preferences.DefaultHost, "version": preferences.Version, "default_host": preferences.DefaultHost,
"git_protocol": preferences.GitProtocol, "editor": preferences.Editor, "git_protocol": preferences.GitProtocol, "editor": preferences.Editor,
"prompt": preferences.Prompt, "pager": preferences.Pager, "prompt": preferences.Prompt, "pager": preferences.Pager,
"browser": preferences.Browser, "color": preferences.Color, "browser": preferences.Browser, "color": preferences.Color,
"hyperlinks": preferences.Hyperlinks, "markdown": preferences.Markdown,
"remote": preferences.Remote, "remote": preferences.Remote,
} }
if host, exists := snapshot.Hosts.Hosts[preferences.DefaultHost]; a.options.host != "" && exists { host, configured := snapshot.Hosts.Hosts[preferences.DefaultHost]
for key, value := range map[string]any{"ssh_host": host.SSHHost, "insecure": host.Insecure, "oauth_client_id": host.OAuthClientID} { 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 values[key] = value
sources[key] = "hosts.yml " + preferences.DefaultHost sources[key] = "hosts.yml " + preferences.DefaultHost
} }
if a.options.user != "" { if a.options.user != "" {
user, exists := matchAccount(host.Users, a.options.user) user, exists := matchAccount(host.Users, a.options.user)
if !exists { if !exists {
return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance")) 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] 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} { 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} {
@@ -176,165 +154,427 @@ Environment overrides:
sources[key] = "hosts.yml " + preferences.DefaultHost + " user " + user sources[key] = "hosts.yml " + preferences.DefaultHost + " user " + user
} }
} }
return values, sources, nil
} }
if len(args) > 0 {
value, ok := values[args[0]] // unknownSetting explains why key has no value: it names an account or instance setting that needs
if !ok { // --host or --user, or no setting at all.
return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown preference %q", args[0])) 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))
} }
values = map[string]any{args[0]: value} return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown setting %q; settings: %s", key, strings.Join(configKeys, ", ")))
} }
if a.options.json != "" {
if source { func (a *application) configCommand() *cobra.Command {
results := map[string]any{} group := &cobra.Command{Use: "config", Short: "Manage settings", Long: `Manage tea's settings.
for key, value := range values {
origin := sources[key] Preferences live in config.yml and instance settings in hosts.yml, in the configuration
if origin == "" { directory ($TEA_CONFIG_DIR, or tea under the XDG config directory); tea config path prints
origin = "default" both files. Environment variables that override settings are listed in tea help environment.
}
results[key] = struct { Preferences (tea config set <key> <value>):
Value any `json:"value"` default_host instance used when no --host, environment variable or repository selects one
Source string `json:"source"` git_protocol https or ssh, for clone and checkout remotes
}{value, origin} editor command that edits bodies
} prompt enabled or disabled
return a.writeJSON(cmd, results) pager command that pages long output
} browser command that opens web pages; never disables opening
return a.writeJSON(cmd, values) color auto, always or never
} hyperlinks auto, always or never: link the first column of lists to its web page
keys := make([]string, 0, len(values)) markdown auto or raw: render markdown bodies on a terminal, or print them as written
for key := range values { remote Git remote that selects the repository
keys = append(keys, key) aliases command shortcuts; manage them with tea alias
}
sort.Strings(keys) Instance settings (tea config set --host <url> <key> <value>):
for _, key := range keys { user active account; change it with tea auth switch
suffix := "" ssh_host SSH host[:port] when it differs from the web host
if source { insecure true skips TLS certificate verification
origin := "default" git_protocol https or ssh, overriding the preference for this instance
if sources[key] != "" { oauth_client_id OAuth application used by tea auth login --oauth
origin = sources[key]
} Account settings (tea config set --host <url> --user <name> <key> <value>):
suffix = "\t" + origin ssh_key, ssh_agent, ssh_certificate_principal, ssh_key_fingerprint`}
} group.AddCommand(&cobra.Command{
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%v%s\n", key, values[key], suffix); err != nil { Use: "path", Short: "Print the paths of the settings files", Args: argsExact(0),
Annotations: map[string]string{"tea.text": "config path prints text", "tea.offline": "true"},
Example: "$ tea config path\n# Settings of another configuration directory\n$ TEA_CONFIG_DIR=~/work/tea tea config path",
RunE: func(cmd *cobra.Command, _ []string) error {
_, 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"))
return err
},
})
var getSource bool
get := &cobra.Command{
Use: "get <key>", Short: "Print the value of a setting", Args: argsExact(1), ValidArgsFunction: a.completeConfig,
Example: `$ tea config get editor
# Show where the value comes from
$ tea config get default_host --show-source
# Read an instance or account setting
$ tea config get ssh_host --host https://gitea.com
$ tea config get ssh_key --host https://gitea.com --user alice`,
RunE: func(cmd *cobra.Command, args []string) error {
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err return err
} }
values, sources, err := a.settingValues(snapshot)
if err != nil {
return err
} }
return nil value, ok := values[args[0]]
if !ok {
return unknownSetting(args[0])
} }
group.AddCommand(command) if a.options.json != "" {
return a.writeSettingsJSON(cmd, map[string]any{args[0]: value}, sources, getSource)
} }
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 { line := fmt.Sprint(value)
if getSource {
line += "\t" + settingSource(sources, args[0])
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), line)
return err
},
}
get.Flags().BoolVar(&getSource, "show-source", false, "Also print where the value comes from")
group.AddCommand(get)
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})
}
return row
},
}, 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 == "" { if a.options.user != "" && a.options.host == "" {
return clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user")) return clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user"))
} }
if a.options.host != "" { if a.options.host == "" {
instance, err := settings.NormalizeHost(a.options.host) value, err := a.setPreference(key, value)
if err != nil {
return err
}
a.reportSet(cmd, key, value, "")
if key == "default_host" && value != "" {
a.warnWithoutAccount(cmd)
}
return nil
}
instance, err := a.setInstanceSetting(key, value)
if err != nil {
return err
}
scope := " for " + instance
if a.options.user != "" {
scope = " for " + a.options.user + " on " + instance
}
a.reportSet(cmd, key, value, scope)
return nil
},
})
return group
}
// settingSource names where the value of key comes from.
func settingSource(sources map[string]string, key string) string {
if source := sources[key]; source != "" {
return source
}
return "default"
}
// writeSettingsJSON writes settings as a key-value object, or with source as objects of value and source.
func (a *application) writeSettingsJSON(cmd *cobra.Command, values map[string]any, sources map[string]string, source bool) error {
if !source {
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 { if err != nil {
return clierr.Wrap(clierr.Usage, err) return clierr.Wrap(clierr.Usage, err)
} }
return a.deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error { 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] host, exists := s.Hosts.Hosts[instance]
if !exists { if !exists {
return clierr.Wrap(clierr.Usage, errors.New("instance is not configured; run tea auth login first")) return clierr.Wrap(clierr.Usage, fmt.Errorf("instance %s is not configured; run tea auth login --host %s", instance, instance))
} }
if a.options.user != "" { if a.options.user != "" {
user, exists := matchAccount(host.Users, a.options.user) user, exists := matchAccount(host.Users, a.options.user)
if !exists { if !exists {
return clierr.Wrap(clierr.Usage, errors.New("account is not configured on the selected instance")) 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] account := host.Users[user]
switch args[0] { switch key {
case "ssh_key": case "ssh_key":
account.SSHKey = args[1] account.SSHKey = value
case "ssh_agent": case "ssh_agent":
if args[1] != "true" && args[1] != "false" { if value != "true" && value != "false" {
return clierr.Wrap(clierr.Usage, errors.New("ssh_agent must be true or false")) return clierr.Wrap(clierr.Usage, errors.New("ssh_agent must be true or false"))
} }
account.SSHAgent = args[1] == "true" account.SSHAgent = value == "true"
case "ssh_certificate_principal": case "ssh_certificate_principal":
account.SSHCertPrincipal = args[1] account.SSHCertPrincipal = value
case "ssh_key_fingerprint": case "ssh_key_fingerprint":
account.SSHKeyFingerprint = args[1] account.SSHKeyFingerprint = value
default: default:
return clierr.Wrap(clierr.Usage, errors.New("supported account settings: ssh_key, ssh_agent, ssh_certificate_principal, ssh_key_fingerprint")) return clierr.Wrap(clierr.Usage, fmt.Errorf("%q is not an account setting; account settings: %s", key, strings.Join(accountKeys, ", ")))
} }
host.Users[user] = account host.Users[user] = account
s.Hosts.Hosts[instance] = host s.Hosts.Hosts[instance] = host
return clierr.Wrap(clierr.Usage, s.Validate()) return clierr.Wrap(clierr.Usage, s.Validate())
} }
switch args[0] { switch key {
case "git_protocol": case "git_protocol":
host.GitProtocol = args[1] host.GitProtocol = value
case "ssh_host": case "ssh_host":
host.SSHHost = args[1] host.SSHHost = value
case "insecure": case "insecure":
if args[1] != "true" && args[1] != "false" { if value != "true" && value != "false" {
return clierr.Wrap(clierr.Usage, errors.New("insecure must be true or false")) return clierr.Wrap(clierr.Usage, errors.New("insecure must be true or false"))
} }
host.Insecure = args[1] == "true" host.Insecure = value == "true"
case "oauth_client_id": case "oauth_client_id":
host.OAuthClientID = args[1] host.OAuthClientID = value
case "user":
return clierr.Wrap(clierr.Usage, errors.New("the active account changes with tea auth switch"))
default: 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])) 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 s.Hosts.Hosts[instance] = host
return clierr.Wrap(clierr.Usage, s.Validate()) 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())
})
}})
return group
}
func (a *application) aliasCommand() *cobra.Command { func (a *application) aliasCommand() *cobra.Command {
group := &cobra.Command{Use: "alias", Short: "Manage shortcuts to built-in commands"} group := &cobra.Command{Use: "alias", Short: "Create shortcuts for tea commands", Long: `Create shortcuts for tea commands.
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 {
return a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error { An alias expands to a tea command with its arguments before tea runs it: after
s.Preferences.Aliases[args[0]] = args[1] tea alias set il "issue list --label bug", tea il --state all runs
return clierr.Wrap(clierr.Usage, aliases.Check(args[0], s.Preferences.Aliases, builtins(cmd.Root()))) 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.`}
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 { var clobber bool
return a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error { set := &cobra.Command{
if _, ok := s.Preferences.Aliases[args[0]]; !ok { Use: "set <name> <expansion>", Short: "Create a shortcut for a tea command",
return clierr.Wrap(clierr.Usage, errors.New("alias does not exist")) 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)
}
return nil
},
}
set.Flags().BoolVar(&clobber, "clobber", false, "Replace an existing alias of the same name")
set.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
return clierr.Wrap(clierr.Usage, fmt.Errorf("%w; quote an expansion with flags: tea alias set il \"issue list --state all\"", err))
})
group.AddCommand(set)
var all bool
remove := &cobra.Command{
Use: "delete [<name>...]", Short: "Delete shortcuts", ValidArgsFunction: a.completeAliases,
Annotations: textOutput("alias deletion does not return a resource"),
Args: func(_ *cobra.Command, args []string) error {
if len(args) == 0 && !all {
return clierr.Wrap(clierr.Usage, errors.New("name the aliases to delete, or pass --all"))
}
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, ", ")))
} }
delete(s.Preferences.Aliases, args[0])
return nil return nil
}) })
}}) if err != nil {
group.AddCommand(&cobra.Command{Use: "list", Short: "List command aliases", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { 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() s, err := a.deps.Settings.Load()
if err != nil { if err != nil {
return err return err
@@ -342,12 +582,14 @@ func (a *application) aliasCommand() *cobra.Command {
if a.options.json != "" { if a.options.json != "" {
return a.writeJSON(cmd, s.Preferences.Aliases) return a.writeJSON(cmd, s.Preferences.Aliases)
} }
names := make([]string, 0, len(s.Preferences.Aliases)) return writeTable(a, cmd, slices.Sorted(maps.Keys(s.Preferences.Aliases)), listSpec[string]{
for name := range s.Preferences.Aliases { Columns: []terminal.Column{{Header: "Name"}, {Header: "Expansion", Flex: true}},
names = append(names, name) Row: func(name string) []terminal.Cell {
} return []terminal.Cell{{Text: name, Bold: true}, {Text: s.Preferences.Aliases[name]}}
sort.Strings(names) },
return a.writeHuman(cmd, table(names, func(name string) []terminal.Cell { return terminal.Row(name, s.Preferences.Aliases[name]) }, a.colorEnabled())) 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)