github.com/chelnak/go-gh@v0.0.2/internal/config/config.go (about)

     1  package config
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"io"
     7  	"io/fs"
     8  	"os"
     9  	"path/filepath"
    10  	"runtime"
    11  	"strings"
    12  
    13  	"github.com/chelnak/go-gh/internal/set"
    14  	"gopkg.in/yaml.v3"
    15  )
    16  
    17  const (
    18  	appData               = "AppData"
    19  	defaultHost           = "github.com"
    20  	ghConfigDir           = "GH_CONFIG_DIR"
    21  	ghEnterpriseToken     = "GH_ENTERPRISE_TOKEN"
    22  	ghHost                = "GH_HOST"
    23  	ghToken               = "GH_TOKEN"
    24  	githubEnterpriseToken = "GITHUB_ENTERPRISE_TOKEN"
    25  	githubToken           = "GITHUB_TOKEN"
    26  	localAppData          = "LocalAppData"
    27  	oauthToken            = "oauth_token"
    28  	xdgConfigHome         = "XDG_CONFIG_HOME"
    29  	xdgDataHome           = "XDG_DATA_HOME"
    30  	xdgStateHome          = "XDG_STATE_HOME"
    31  )
    32  
    33  type Config interface {
    34  	Get(key string) (string, error)
    35  	GetForHost(host string, key string) (string, error)
    36  	Host() string
    37  	Hosts() []string
    38  	AuthToken(host string) (string, error)
    39  }
    40  
    41  type config struct {
    42  	global configMap
    43  	hosts  configMap
    44  }
    45  
    46  func (c config) Get(key string) (string, error) {
    47  	return c.global.getStringValue(key)
    48  }
    49  
    50  func (c config) GetForHost(host, key string) (string, error) {
    51  	hostEntry, err := c.hosts.findEntry(host)
    52  	if err != nil {
    53  		return "", err
    54  	}
    55  	hostMap := configMap{Root: hostEntry.ValueNode}
    56  	return hostMap.getStringValue(key)
    57  }
    58  
    59  func (c config) Host() string {
    60  	if host := os.Getenv(ghHost); host != "" {
    61  		return host
    62  	}
    63  	entries := c.hosts.keys()
    64  	if len(entries) == 1 {
    65  		return entries[0]
    66  	}
    67  	return defaultHost
    68  }
    69  
    70  func (c config) Hosts() []string {
    71  	hosts := set.NewStringSet()
    72  	if host := os.Getenv(ghHost); host != "" {
    73  		hosts.Add(host)
    74  	}
    75  	entries := c.hosts.keys()
    76  	hosts.AddValues(entries)
    77  	return hosts.ToSlice()
    78  }
    79  
    80  func (c config) AuthToken(host string) (string, error) {
    81  	hostname := normalizeHostname(host)
    82  	if isEnterprise(hostname) {
    83  		if token := os.Getenv(ghEnterpriseToken); token != "" {
    84  			return token, nil
    85  		}
    86  		if token := os.Getenv(githubEnterpriseToken); token != "" {
    87  			return token, nil
    88  		}
    89  		if token, err := c.GetForHost(hostname, oauthToken); err == nil {
    90  			return token, nil
    91  		}
    92  		return "", NotFoundError{errors.New("not found")}
    93  	}
    94  
    95  	if token := os.Getenv(ghToken); token != "" {
    96  		return token, nil
    97  	}
    98  	if token := os.Getenv(githubToken); token != "" {
    99  		return token, nil
   100  	}
   101  	if token, err := c.GetForHost(hostname, oauthToken); err == nil {
   102  		return token, nil
   103  	}
   104  	return "", NotFoundError{errors.New("not found")}
   105  }
   106  
   107  func isEnterprise(host string) bool {
   108  	return host != defaultHost
   109  }
   110  
   111  func normalizeHostname(host string) string {
   112  	hostname := strings.ToLower(host)
   113  	if strings.HasSuffix(hostname, "."+defaultHost) {
   114  		return defaultHost
   115  	}
   116  	return hostname
   117  }
   118  
   119  func fromString(str string) (Config, error) {
   120  	root, err := parseData([]byte(str))
   121  	if err != nil {
   122  		return nil, err
   123  	}
   124  	cfg := config{}
   125  	globalMap := configMap{Root: root}
   126  	cfg.global = globalMap
   127  	hostsEntry, err := globalMap.findEntry("hosts")
   128  	if err == nil {
   129  		cfg.hosts = configMap{Root: hostsEntry.ValueNode}
   130  	}
   131  	return cfg, nil
   132  }
   133  
   134  func defaultConfig() Config {
   135  	return config{global: configMap{Root: defaultGlobal().Content[0]}}
   136  }
   137  
   138  func Load() (Config, error) {
   139  	return load(configFile(), hostsConfigFile())
   140  }
   141  
   142  func load(globalFilePath, hostsFilePath string) (Config, error) {
   143  	var readErr error
   144  	var parseErr error
   145  	globalData, readErr := readFile(globalFilePath)
   146  	if readErr != nil && !errors.Is(readErr, fs.ErrNotExist) {
   147  		return nil, readErr
   148  	}
   149  
   150  	// Use defaultGlobal node if globalFile does not exist or is empty.
   151  	global := defaultGlobal().Content[0]
   152  	if len(globalData) > 0 {
   153  		global, parseErr = parseData(globalData)
   154  	}
   155  	if parseErr != nil {
   156  		return nil, parseErr
   157  	}
   158  
   159  	hostsData, readErr := readFile(hostsFilePath)
   160  	if readErr != nil && !os.IsNotExist(readErr) {
   161  		return nil, readErr
   162  	}
   163  
   164  	// Use nil if hostsFile does not exist or is empty.
   165  	var hosts *yaml.Node
   166  	if len(hostsData) > 0 {
   167  		hosts, parseErr = parseData(hostsData)
   168  	}
   169  	if parseErr != nil {
   170  		return nil, parseErr
   171  	}
   172  
   173  	cfg := config{
   174  		global: configMap{Root: global},
   175  		hosts:  configMap{Root: hosts},
   176  	}
   177  
   178  	return cfg, nil
   179  }
   180  
   181  // Config path precedence: GH_CONFIG_DIR, XDG_CONFIG_HOME, AppData (windows only), HOME.
   182  func configDir() string {
   183  	var path string
   184  	if a := os.Getenv(ghConfigDir); a != "" {
   185  		path = a
   186  	} else if b := os.Getenv(xdgConfigHome); b != "" {
   187  		path = filepath.Join(b, "gh")
   188  	} else if c := os.Getenv(appData); runtime.GOOS == "windows" && c != "" {
   189  		path = filepath.Join(c, "GitHub CLI")
   190  	} else {
   191  		d, _ := os.UserHomeDir()
   192  		path = filepath.Join(d, ".config", "gh")
   193  	}
   194  	return path
   195  }
   196  
   197  // State path precedence: XDG_STATE_HOME, LocalAppData (windows only), HOME.
   198  func stateDir() string {
   199  	var path string
   200  	if a := os.Getenv(xdgStateHome); a != "" {
   201  		path = filepath.Join(a, "gh")
   202  	} else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" {
   203  		path = filepath.Join(b, "GitHub CLI")
   204  	} else {
   205  		c, _ := os.UserHomeDir()
   206  		path = filepath.Join(c, ".local", "state", "gh")
   207  	}
   208  	return path
   209  }
   210  
   211  // Data path precedence: XDG_DATA_HOME, LocalAppData (windows only), HOME.
   212  func dataDir() string {
   213  	var path string
   214  	if a := os.Getenv(xdgDataHome); a != "" {
   215  		path = filepath.Join(a, "gh")
   216  	} else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" {
   217  		path = filepath.Join(b, "GitHub CLI")
   218  	} else {
   219  		c, _ := os.UserHomeDir()
   220  		path = filepath.Join(c, ".local", "share", "gh")
   221  	}
   222  	return path
   223  }
   224  
   225  func configFile() string {
   226  	return filepath.Join(configDir(), "config.yml")
   227  }
   228  
   229  func hostsConfigFile() string {
   230  	return filepath.Join(configDir(), "hosts.yml")
   231  }
   232  
   233  func readFile(filename string) ([]byte, error) {
   234  	f, err := os.Open(filename)
   235  	if err != nil {
   236  		return nil, err
   237  	}
   238  	defer f.Close()
   239  	data, err := io.ReadAll(f)
   240  	if err != nil {
   241  		return nil, err
   242  	}
   243  	return data, nil
   244  }
   245  
   246  func parseData(data []byte) (*yaml.Node, error) {
   247  	var root yaml.Node
   248  	err := yaml.Unmarshal(data, &root)
   249  	if err != nil {
   250  		return nil, fmt.Errorf("invalid config file: %w", err)
   251  	}
   252  	if len(root.Content) == 0 || root.Content[0].Kind != yaml.MappingNode {
   253  		return nil, fmt.Errorf("invalid config file")
   254  	}
   255  	return root.Content[0], nil
   256  }
   257  
   258  func defaultGlobal() *yaml.Node {
   259  	return &yaml.Node{
   260  		Kind: yaml.DocumentNode,
   261  		Content: []*yaml.Node{
   262  			{
   263  				Kind: yaml.MappingNode,
   264  				Content: []*yaml.Node{
   265  					{
   266  						HeadComment: "What protocol to use when performing git operations. Supported values: ssh, https",
   267  						Kind:        yaml.ScalarNode,
   268  						Value:       "git_protocol",
   269  					},
   270  					{
   271  						Kind:  yaml.ScalarNode,
   272  						Value: "https",
   273  					},
   274  					{
   275  						HeadComment: "What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.",
   276  						Kind:        yaml.ScalarNode,
   277  						Value:       "editor",
   278  					},
   279  					{
   280  						Kind:  yaml.ScalarNode,
   281  						Value: "",
   282  					},
   283  					{
   284  						HeadComment: "When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled",
   285  						Kind:        yaml.ScalarNode,
   286  						Value:       "prompt",
   287  					},
   288  					{
   289  						Kind:  yaml.ScalarNode,
   290  						Value: "enabled",
   291  					},
   292  					{
   293  						HeadComment: "A pager program to send command output to, e.g. \"less\". Set the value to \"cat\" to disable the pager.",
   294  						Kind:        yaml.ScalarNode,
   295  						Value:       "pager",
   296  					},
   297  					{
   298  						Kind:  yaml.ScalarNode,
   299  						Value: "",
   300  					},
   301  					{
   302  						HeadComment: "Aliases allow you to create nicknames for gh commands",
   303  						Kind:        yaml.ScalarNode,
   304  						Value:       "aliases",
   305  					},
   306  					{
   307  						Kind: yaml.MappingNode,
   308  						Content: []*yaml.Node{
   309  							{
   310  								Kind:  yaml.ScalarNode,
   311  								Value: "co",
   312  							},
   313  							{
   314  								Kind:  yaml.ScalarNode,
   315  								Value: "pr checkout",
   316  							},
   317  						},
   318  					},
   319  					{
   320  						HeadComment: "The path to a unix socket through which send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport.",
   321  						Kind:        yaml.ScalarNode,
   322  						Value:       "http_unix_socket",
   323  					},
   324  					{
   325  						Kind:  yaml.ScalarNode,
   326  						Value: "",
   327  					},
   328  					{
   329  						HeadComment: "What web browser gh should use when opening URLs. If blank, will refer to environment.",
   330  						Kind:        yaml.ScalarNode,
   331  						Value:       "browser",
   332  					},
   333  					{
   334  						Kind:  yaml.ScalarNode,
   335  						Value: "",
   336  					},
   337  				},
   338  			},
   339  		},
   340  	}
   341  }