From 5e8aa1011f02ad0ccaa76045cad599bf1ce8ca11 Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 11 Oct 2026 23:12:26 +0200 Subject: [PATCH] fix(config): print only the value of config get and add display preferences tea config get printed "keyvalue", 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 --- internal/app/app.go | 8 +- internal/app/app_test.go | 10 - internal/app/completion.go | 9 - internal/app/config.go | 710 ++++++++++++++++++++---------- internal/app/config_test.go | 167 ++++++- internal/app/human_output.go | 7 +- internal/app/human_output_test.go | 10 +- internal/settings/store.go | 16 +- internal/settings/store_test.go | 21 +- 9 files changed, 687 insertions(+), 271 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 66e5e679..55be3451 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -279,7 +279,7 @@ func (a *application) checkOutputFlags(cmd *cobra.Command) error { // offline reports commands that must work without valid settings. 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 } switch cmd.Name() { @@ -492,12 +492,6 @@ func commandIndex(root *cobra.Command, args []string) int { 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 { if cmd.Name() == "status" && cmd.Parent() != nil { if cmd.Parent().Name() == "pr" { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 6f2b16af..c1cb78cf 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -415,16 +415,11 @@ func walkCommands(cmd *cobra.Command, visit func(*cobra.Command)) { // have yet to complete; see TestHelpTextsAreComplete. const ( shortsToFix = ` - config get - config list notification mark-read notification mark-unread pr review cancel-request ` examplesToWrite = ` - alias delete - alias list - alias set api auth login auth logout @@ -439,10 +434,6 @@ const ( branch rename branch unprotect branch view - config get - config list - config path - config set doctor issue attachment delete issue attachment download @@ -557,7 +548,6 @@ const ( wiki view ` longsToWrite = ` - alias auth branch issue diff --git a/internal/app/completion.go b/internal/app/completion.go index 58394a8b..91ef9fb2 100644 --- a/internal/app/completion.go +++ b/internal/app/completion.go @@ -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 } -// 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 // for default_host, or the Git remotes for remote. func (a *application) completeConfig(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { diff --git a/internal/app/config.go b/internal/app/config.go index 2edf649f..e041e57f 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -6,8 +6,10 @@ package app import ( "errors" "fmt" + "maps" "path/filepath" - "sort" + "slices" + "strconv" "strings" "gitea.dev/tea/internal/aliases" @@ -17,6 +19,26 @@ import ( "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) { preferences := snapshot.Preferences sources := map[string]string{} @@ -42,7 +64,7 @@ func (a *application) effectivePreferences(snapshot settings.Snapshot) (settings } } if host != "" { - normalized, err := settings.NormalizeHost(host) + normalized, err := settings.ResolveHost(host, snapshot.Hosts.Hosts) if err != nil { return preferences, nil, clierr.Wrap(clierr.Usage, err) } @@ -85,269 +107,489 @@ func (a *application) effectivePreferences(snapshot settings.Snapshot) (settings 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 { - 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 -($TEA_CONFIG_DIR, or tea under the XDG config directory); tea config path prints both. +Preferences live in config.yml and instance settings in hosts.yml, in the configuration +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 ): - 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 +Preferences (tea config set ): + default_host instance used when no --host, environment variable or repository selects one + git_protocol https or ssh, for clone and checkout remotes + editor command that edits bodies prompt enabled or disabled - pager command used to page human output - browser command used to open web pages; never disables opening + pager command that pages long output + browser command that opens 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 + hyperlinks auto, always or never: link the first column of lists to its web page + 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 ): - user active account, changed only by tea auth switch +Instance settings (tea config set --host ): + user active account; change it with 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 + git_protocol https or ssh, overriding the preference for this instance + oauth_client_id OAuth application used by tea auth login --oauth -Per-account SSH settings (tea config set --host --user ): - 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 " - 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")) - } +Account settings (tea config set --host --user ): + ssh_key, ssh_agent, ssh_certificate_principal, ssh_key_fingerprint`} + group.AddCommand(&cobra.Command{ + 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 ", 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 } - preferences, sources, err := a.effectivePreferences(snapshot) + values, sources, err := a.settingValues(snapshot) if err != nil { return 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{ - "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} + value, ok := values[args[0]] + if !ok { + return unknownSetting(args[0]) } if a.options.json != "" { - if source { - 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) + return a.writeSettingsJSON(cmd, map[string]any{args[0]: value}, sources, getSource) } - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) + line := fmt.Sprint(value) + if getSource { + line += "\t" + settingSource(sources, args[0]) } - sort.Strings(keys) - for _, key := range keys { - suffix := "" - if source { - origin := "default" - if sources[key] != "" { - origin = sources[key] + _, 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}) } - suffix = "\t" + origin - } - if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%v%s\n", key, values[key], suffix); err != nil { + 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 ", 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 } + a.reportSet(cmd, key, value, "") + if key == "default_host" && value != "" { + a.warnWithoutAccount(cmd) + } + return nil } - return nil - } - group.AddCommand(command) - } - group.AddCommand(&cobra.Command{Use: "set ", 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) + instance, err := a.setInstanceSetting(key, value) if err != nil { - return clierr.Wrap(clierr.Usage, err) + return err } - return a.deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error { - host, exists := s.Hosts.Hosts[instance] - if !exists { - 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])) + scope := " for " + instance + if a.options.user != "" { + scope = " for " + a.options.user + " on " + instance } - return clierr.Wrap(clierr.Usage, s.Validate()) - }) - }}) + a.reportSet(cmd, key, value, scope) + return nil + }, + }) return group } -func (a *application) aliasCommand() *cobra.Command { - group := &cobra.Command{Use: "alias", Short: "Manage shortcuts to built-in commands"} - group.AddCommand(&cobra.Command{Use: "set ", 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 { - s.Preferences.Aliases[args[0]] = args[1] - return clierr.Wrap(clierr.Usage, aliases.Check(args[0], s.Preferences.Aliases, builtins(cmd.Root()))) - }) - }}) - group.AddCommand(&cobra.Command{Use: "delete ", 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 { - return a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error { - if _, ok := s.Preferences.Aliases[args[0]]; !ok { - return clierr.Wrap(clierr.Usage, errors.New("alias does not exist")) +// 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 { + 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 ", 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 - }) - }}) - group.AddCommand(&cobra.Command{Use: "list", Short: "List command aliases", Args: argsExact(0), 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) - } - names := make([]string, 0, len(s.Preferences.Aliases)) - for name := range s.Preferences.Aliases { - names = append(names, 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())) - }}) + }, + } + 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 [...]", 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, ", "))) + } + 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 ", + }, listPage{Number: 1}) + }, + }) return group } diff --git a/internal/app/config_test.go b/internal/app/config_test.go index 2ab4c6b5..ad0c58ca 100644 --- a/internal/app/config_test.go +++ b/internal/app/config_test.go @@ -7,7 +7,9 @@ import ( "context" "os" "path/filepath" + "strings" "testing" + "time" "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/settings" @@ -94,11 +96,170 @@ func TestConfigHelpDocumentsSettingsAndEnvironment(t *testing.T) { deps, out, _ := testDeps(t) require.NoError(t, Execute(context.Background(), deps, []string{"config", "--help"})) 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", - "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", + "tea help environment", } { 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 \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) } diff --git a/internal/app/human_output.go b/internal/app/human_output.go index 17debb5c..6b4014c1 100644 --- a/internal/app/human_output.go +++ b/internal/app/human_output.go @@ -30,8 +30,7 @@ func (a *application) colorEnabled() bool { 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 -// (auto, raw) preferences are read from TEA_HYPERLINKS and TEA_MARKDOWN until config.yml stores them. +// display describes stdout for tables and views under the color, hyperlinks and markdown preferences. func (a *application) display() terminal.Display { preferences, _ := a.preferences() streams := a.deps.Streams @@ -39,8 +38,8 @@ func (a *application) display() terminal.Display { TTY: streams.OutTTY, Color: terminal.ColorEnabled(preferences.Color, streams.OutTTY, a.deps.Getenv), TrueColor: terminal.TrueColor(a.deps.Getenv), - Links: terminal.HyperlinksEnabled(a.deps.Getenv("TEA_HYPERLINKS"), streams.OutTTY, a.deps.Getenv), - Markdown: terminal.MarkdownEnabled(a.deps.Getenv("TEA_MARKDOWN"), streams.OutTTY), + Links: terminal.HyperlinksEnabled(preferences.Hyperlinks, streams.OutTTY, a.deps.Getenv), + Markdown: terminal.MarkdownEnabled(preferences.Markdown, streams.OutTTY), Width: streams.Width, Now: a.deps.Now(), } diff --git a/internal/app/human_output_test.go b/internal/app/human_output_test.go index 79b5bbbc..854d7d6f 100644 --- a/internal/app/human_output_test.go +++ b/internal/app/human_output_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + "gitea.dev/tea/internal/settings" "gitea.dev/tea/internal/terminal" "github.com/mattn/go-runewidth" "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 { require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", config[i], config[i+1]})) } + diagnostics.Reset() 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"}, } { 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.Equal(t, 1, pager.calls) require.Equal(t, tc.want, pager.input) @@ -341,7 +343,11 @@ func TestWriteView(t *testing.T) { t.Run(tc.name, func(t *testing.T) { deps, out, _ := testDeps(t) 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) require.NoError(t, writeView(a, cmd, item, view)) require.Equal(t, tc.want, out.String()) diff --git a/internal/settings/store.go b/internal/settings/store.go index 4145ac3d..9f376236 100644 --- a/internal/settings/store.go +++ b/internal/settings/store.go @@ -52,6 +52,8 @@ type Preferences struct { Pager string `yaml:"pager,omitempty" json:"pager"` Browser string `yaml:"browser,omitempty" json:"browser"` 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"` 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. 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. @@ -129,6 +131,12 @@ func (p Preferences) stored() Preferences { if p.Color == defaults.Color { p.Color = "" } + if p.Hyperlinks == defaults.Hyperlinks { + p.Hyperlinks = "" + } + if p.Markdown == defaults.Markdown { + p.Markdown = "" + } return p } @@ -247,6 +255,12 @@ func (s Snapshot) Validate() error { if p.Color != "auto" && p.Color != "always" && p.Color != "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 != "" { normalized, err := NormalizeHost(p.DefaultHost) if err != nil || normalized != p.DefaultHost { diff --git a/internal/settings/store_test.go b/internal/settings/store_test.go index bc6564f5..73a6302c 100644 --- a/internal/settings/store_test.go +++ b/internal/settings/store_test.go @@ -120,7 +120,7 @@ func TestUpdatePersistsOnlyExplicitPreferences(t *testing.T) { snapshot, err := s.Load() require.NoError(t, err) 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.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)) } +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) { for method, want := range map[string]bool{AuthToken: false, AuthOAuth: false, AuthSSH: true, AuthHTTPSign: true} { require.Equal(t, want, Account{AuthMethod: method}.Tokenless(), method)