...

Source file src/github.com/cli/go-gh/v2/pkg/config/config.go

Documentation: github.com/cli/go-gh/v2/pkg/config

     1  // Package config is a set of types for interacting with the gh configuration files.
     2  // Note: This package is intended for use only in gh, any other use cases are subject
     3  // to breakage and non-backwards compatible updates.
     4  package config
     5  
     6  import (
     7  	"errors"
     8  	"io"
     9  	"os"
    10  	"path/filepath"
    11  	"runtime"
    12  	"sync"
    13  
    14  	"github.com/cli/go-gh/v2/internal/yamlmap"
    15  )
    16  
    17  const (
    18  	appData       = "AppData"
    19  	ghConfigDir   = "GH_CONFIG_DIR"
    20  	localAppData  = "LocalAppData"
    21  	xdgConfigHome = "XDG_CONFIG_HOME"
    22  	xdgDataHome   = "XDG_DATA_HOME"
    23  	xdgStateHome  = "XDG_STATE_HOME"
    24  	xdgCacheHome  = "XDG_CACHE_HOME"
    25  )
    26  
    27  var (
    28  	cfg     *Config
    29  	once    sync.Once
    30  	loadErr error
    31  )
    32  
    33  // Config is a in memory representation of the gh configuration files.
    34  // It can be thought of as map where entries consist of a key that
    35  // correspond to either a string value or a map value, allowing for
    36  // multi-level maps.
    37  type Config struct {
    38  	entries *yamlmap.Map
    39  	mu      sync.RWMutex
    40  }
    41  
    42  // Get a string value from a Config.
    43  // The keys argument is a sequence of key values so that nested
    44  // entries can be retrieved. A undefined string will be returned
    45  // if trying to retrieve a key that corresponds to a map value.
    46  // Returns "", KeyNotFoundError if any of the keys can not be found.
    47  func (c *Config) Get(keys []string) (string, error) {
    48  	c.mu.RLock()
    49  	defer c.mu.RUnlock()
    50  	m := c.entries
    51  	for _, key := range keys {
    52  		var err error
    53  		m, err = m.FindEntry(key)
    54  		if err != nil {
    55  			return "", &KeyNotFoundError{key}
    56  		}
    57  	}
    58  	return m.Value, nil
    59  }
    60  
    61  // Keys enumerates a Config's keys.
    62  // The keys argument is a sequence of key values so that nested
    63  // map values can be have their keys enumerated.
    64  // Returns nil, KeyNotFoundError if any of the keys can not be found.
    65  func (c *Config) Keys(keys []string) ([]string, error) {
    66  	c.mu.RLock()
    67  	defer c.mu.RUnlock()
    68  	m := c.entries
    69  	for _, key := range keys {
    70  		var err error
    71  		m, err = m.FindEntry(key)
    72  		if err != nil {
    73  			return nil, &KeyNotFoundError{key}
    74  		}
    75  	}
    76  	return m.Keys(), nil
    77  }
    78  
    79  // Remove an entry from a Config.
    80  // The keys argument is a sequence of key values so that nested
    81  // entries can be removed. Removing an entry that has nested
    82  // entries removes those also.
    83  // Returns KeyNotFoundError if any of the keys can not be found.
    84  func (c *Config) Remove(keys []string) error {
    85  	c.mu.Lock()
    86  	defer c.mu.Unlock()
    87  	m := c.entries
    88  	for i := 0; i < len(keys)-1; i++ {
    89  		var err error
    90  		key := keys[i]
    91  		m, err = m.FindEntry(key)
    92  		if err != nil {
    93  			return &KeyNotFoundError{key}
    94  		}
    95  	}
    96  	err := m.RemoveEntry(keys[len(keys)-1])
    97  	if err != nil {
    98  		return &KeyNotFoundError{keys[len(keys)-1]}
    99  	}
   100  	return nil
   101  }
   102  
   103  // Set a string value in a Config.
   104  // The keys argument is a sequence of key values so that nested
   105  // entries can be set. If any of the keys do not exist they will
   106  // be created. If the string value to be set is empty it will be
   107  // represented as null not an empty string when written.
   108  //
   109  //	var c *Config
   110  //	c.Set([]string{"key"}, "")
   111  //	Write(c) // writes `key: ` not `key: ""`
   112  func (c *Config) Set(keys []string, value string) {
   113  	c.mu.Lock()
   114  	defer c.mu.Unlock()
   115  	m := c.entries
   116  	for i := 0; i < len(keys)-1; i++ {
   117  		key := keys[i]
   118  		entry, err := m.FindEntry(key)
   119  		if err != nil {
   120  			entry = yamlmap.MapValue()
   121  			m.AddEntry(key, entry)
   122  		}
   123  		m = entry
   124  	}
   125  	val := yamlmap.StringValue(value)
   126  	if value == "" {
   127  		val = yamlmap.NullValue()
   128  	}
   129  	m.SetEntry(keys[len(keys)-1], val)
   130  }
   131  
   132  func (c *Config) deepCopy() *Config {
   133  	return ReadFromString(c.entries.String())
   134  }
   135  
   136  // Read gh configuration files from the local file system and
   137  // returns a Config. A copy of the fallback configuration will
   138  // be returned when there are no configuration files to load.
   139  // If there are no configuration files and no fallback configuration
   140  // an empty configuration will be returned.
   141  var Read = func(fallback *Config) (*Config, error) {
   142  	once.Do(func() {
   143  		cfg, loadErr = load(generalConfigFile(), hostsConfigFile(), fallback)
   144  	})
   145  	return cfg, loadErr
   146  }
   147  
   148  // ReadFromString takes a yaml string and returns a Config.
   149  func ReadFromString(str string) *Config {
   150  	m, _ := mapFromString(str)
   151  	if m == nil {
   152  		m = yamlmap.MapValue()
   153  	}
   154  	return &Config{entries: m}
   155  }
   156  
   157  // Write gh configuration files to the local file system.
   158  // It will only write gh configuration files that have been modified
   159  // since last being read.
   160  func Write(c *Config) error {
   161  	c.mu.Lock()
   162  	defer c.mu.Unlock()
   163  	hosts, err := c.entries.FindEntry("hosts")
   164  	if err == nil && hosts.IsModified() {
   165  		err := writeFile(hostsConfigFile(), []byte(hosts.String()))
   166  		if err != nil {
   167  			return err
   168  		}
   169  		hosts.SetUnmodified()
   170  	}
   171  
   172  	if c.entries.IsModified() {
   173  		// Hosts gets written to a different file above so remove it
   174  		// before writing and add it back in after writing.
   175  		hostsMap, hostsErr := c.entries.FindEntry("hosts")
   176  		if hostsErr == nil {
   177  			_ = c.entries.RemoveEntry("hosts")
   178  		}
   179  		err := writeFile(generalConfigFile(), []byte(c.entries.String()))
   180  		if err != nil {
   181  			return err
   182  		}
   183  		c.entries.SetUnmodified()
   184  		if hostsErr == nil {
   185  			c.entries.AddEntry("hosts", hostsMap)
   186  		}
   187  	}
   188  
   189  	return nil
   190  }
   191  
   192  func load(generalFilePath, hostsFilePath string, fallback *Config) (*Config, error) {
   193  	generalMap, err := mapFromFile(generalFilePath)
   194  	if err != nil && !os.IsNotExist(err) {
   195  		if errors.Is(err, yamlmap.ErrInvalidYaml) ||
   196  			errors.Is(err, yamlmap.ErrInvalidFormat) {
   197  			return nil, &InvalidConfigFileError{Path: generalFilePath, Err: err}
   198  		}
   199  		return nil, err
   200  	}
   201  
   202  	if generalMap == nil {
   203  		generalMap = yamlmap.MapValue()
   204  	}
   205  
   206  	hostsMap, err := mapFromFile(hostsFilePath)
   207  	if err != nil && !os.IsNotExist(err) {
   208  		if errors.Is(err, yamlmap.ErrInvalidYaml) ||
   209  			errors.Is(err, yamlmap.ErrInvalidFormat) {
   210  			return nil, &InvalidConfigFileError{Path: hostsFilePath, Err: err}
   211  		}
   212  		return nil, err
   213  	}
   214  
   215  	if hostsMap != nil && !hostsMap.Empty() {
   216  		generalMap.AddEntry("hosts", hostsMap)
   217  		generalMap.SetUnmodified()
   218  	}
   219  
   220  	if generalMap.Empty() && fallback != nil {
   221  		return fallback.deepCopy(), nil
   222  	}
   223  
   224  	return &Config{entries: generalMap}, nil
   225  }
   226  
   227  func generalConfigFile() string {
   228  	return filepath.Join(ConfigDir(), "config.yml")
   229  }
   230  
   231  func hostsConfigFile() string {
   232  	return filepath.Join(ConfigDir(), "hosts.yml")
   233  }
   234  
   235  func mapFromFile(filename string) (*yamlmap.Map, error) {
   236  	data, err := readFile(filename)
   237  	if err != nil {
   238  		return nil, err
   239  	}
   240  	return yamlmap.Unmarshal(data)
   241  }
   242  
   243  func mapFromString(str string) (*yamlmap.Map, error) {
   244  	return yamlmap.Unmarshal([]byte(str))
   245  }
   246  
   247  // Config path precedence: GH_CONFIG_DIR, XDG_CONFIG_HOME, AppData (windows only), HOME.
   248  func ConfigDir() string {
   249  	var path string
   250  	if a := os.Getenv(ghConfigDir); a != "" {
   251  		path = a
   252  	} else if b := os.Getenv(xdgConfigHome); b != "" {
   253  		path = filepath.Join(b, "gh")
   254  	} else if c := os.Getenv(appData); runtime.GOOS == "windows" && c != "" {
   255  		path = filepath.Join(c, "GitHub CLI")
   256  	} else {
   257  		d, _ := os.UserHomeDir()
   258  		path = filepath.Join(d, ".config", "gh")
   259  	}
   260  	return path
   261  }
   262  
   263  // State path precedence: XDG_STATE_HOME, LocalAppData (windows only), HOME.
   264  func StateDir() string {
   265  	var path string
   266  	if a := os.Getenv(xdgStateHome); a != "" {
   267  		path = filepath.Join(a, "gh")
   268  	} else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" {
   269  		path = filepath.Join(b, "GitHub CLI")
   270  	} else {
   271  		c, _ := os.UserHomeDir()
   272  		path = filepath.Join(c, ".local", "state", "gh")
   273  	}
   274  	return path
   275  }
   276  
   277  // Data path precedence: XDG_DATA_HOME, LocalAppData (windows only), HOME.
   278  func DataDir() string {
   279  	var path string
   280  	if a := os.Getenv(xdgDataHome); a != "" {
   281  		path = filepath.Join(a, "gh")
   282  	} else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" {
   283  		path = filepath.Join(b, "GitHub CLI")
   284  	} else {
   285  		c, _ := os.UserHomeDir()
   286  		path = filepath.Join(c, ".local", "share", "gh")
   287  	}
   288  	return path
   289  }
   290  
   291  // Cache path precedence: XDG_CACHE_HOME, LocalAppData (windows only), HOME, legacy gh-cli-cache.
   292  func CacheDir() string {
   293  	if a := os.Getenv(xdgCacheHome); a != "" {
   294  		return filepath.Join(a, "gh")
   295  	} else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" {
   296  		return filepath.Join(b, "GitHub CLI")
   297  	} else if c, err := os.UserHomeDir(); err == nil {
   298  		return filepath.Join(c, ".cache", "gh")
   299  	} else {
   300  		// Note that this has a minor security issue because /tmp is world-writeable.
   301  		// As such, it is possible for other users on a shared system to overwrite cached data.
   302  		// The practical risk of this is low, but it's worth calling out as a risk.
   303  		// I've included this here for backwards compatibility but we should consider removing it.
   304  		return filepath.Join(os.TempDir(), "gh-cli-cache")
   305  	}
   306  }
   307  
   308  func readFile(filename string) ([]byte, error) {
   309  	f, err := os.Open(filename)
   310  	if err != nil {
   311  		return nil, err
   312  	}
   313  	defer f.Close()
   314  	data, err := io.ReadAll(f)
   315  	if err != nil {
   316  		return nil, err
   317  	}
   318  	return data, nil
   319  }
   320  
   321  func writeFile(filename string, data []byte) (writeErr error) {
   322  	if writeErr = os.MkdirAll(filepath.Dir(filename), 0771); writeErr != nil {
   323  		return
   324  	}
   325  	var file *os.File
   326  	if file, writeErr = os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600); writeErr != nil {
   327  		return
   328  	}
   329  	defer func() {
   330  		if err := file.Close(); writeErr == nil && err != nil {
   331  			writeErr = err
   332  		}
   333  	}()
   334  	_, writeErr = file.Write(data)
   335  	return
   336  }
   337  

View as plain text