// Package config reads the per-host settings loomctl needs to talk to a git host. // // The config is not only a secret. It is how you talk to a host at all, which is // why it is keyed by host rather than being a single token. It lives in the // user's home directory and never in a repository — see .loom/event-log.md, // "decided by fallback: where the credential lives". package config import ( "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "strings" ) // Host is what we know about one git host. type Host struct { // Token is a read-only personal access token. It must not carry write // scope: loomctl never writes over the network. Token string `json:"token,omitempty"` } type Config struct { Hosts map[string]Host `json:"hosts"` } // Path is where the config lives. Never inside a repository. func Path() string { if p := os.Getenv("LOOMCTL_CONFIG"); p != "" { return p } home, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(home, ".config", "loomctl", "config.json") } // Load reads the config. A missing file is not an error: everything loomctl does // against a public repository works with no credential at all. func Load() (*Config, error) { c := &Config{Hosts: map[string]Host{}} p := Path() if p == "" { return c, nil } b, err := os.ReadFile(p) if errors.Is(err, fs.ErrNotExist) { return c, nil } if err != nil { return nil, fmt.Errorf("reading %s: %w", p, err) } if err := json.Unmarshal(b, c); err != nil { return nil, fmt.Errorf("parsing %s: %w", p, err) } if c.Hosts == nil { c.Hosts = map[string]Host{} } return c, nil } // TokenFor returns the token for a host, or "" if we have none. // // An environment variable wins over the file, so a token can be supplied for one // invocation without ever being written to disk. func (c *Config) TokenFor(host string) string { if t := os.Getenv("LOOMCTL_TOKEN_" + envKey(host)); t != "" { return t } if t := os.Getenv("LOOMCTL_TOKEN"); t != "" { return t } return c.Hosts[host].Token } // envKey turns a hostname into the shape an environment variable can carry. func envKey(host string) string { r := strings.NewReplacer(".", "_", "-", "_", ":", "_") return strings.ToUpper(r.Replace(host)) }