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 }