// 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 a set of named contexts with one current, and why a host's URL // shapes live here rather than in what a person types. Nobody should have to // know that gitea serves raw files from /raw/branch// to adopt a // document. // // It lives in the user's home directory and never in a repository. package config import ( "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "strings" ) // Context is one named way of talking to one host. type Context struct { Host string `json:"host"` // Flavor selects the URL shapes: gitea, github or gitlab. Empty means gitea. Flavor string `json:"flavor,omitempty"` // Token is read-only. loomctl never writes over the network, so a token it // is given should not carry write scope. Token string `json:"token,omitempty"` } type Config struct { CurrentContext string `json:"current-context"` Contexts map[string]Context `json:"contexts"` } // 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 and no context. func Load() (*Config, error) { c := &Config{Contexts: map[string]Context{}} 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.Contexts == nil { c.Contexts = map[string]Context{} } return c, nil } // Current returns the context a bare `owner/repo` is resolved against. func (c *Config) Current() (Context, error) { if c.CurrentContext == "" { return Context{}, fmt.Errorf("no current-context in %s — a bare owner/repo has no host to resolve against", Path()) } ctx, ok := c.Contexts[c.CurrentContext] if !ok { return Context{}, fmt.Errorf("current-context %q is not defined in %s", c.CurrentContext, Path()) } if ctx.Host == "" { return Context{}, fmt.Errorf("context %q has no host", c.CurrentContext) } return ctx, 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 } for _, ctx := range c.Contexts { if ctx.Host == host && ctx.Token != "" { return ctx.Token } } return "" } func envKey(host string) string { r := strings.NewReplacer(".", "_", "-", "_", ":", "_") return strings.ToUpper(r.Replace(host)) } // RawURL builds the address a document is served from, given owner/repo and a // path inside the repository. // // For gitea the short form is deliberate: the host redirects it to the resolved // branch, so the URL recorded in the lock names a branch rather than a moving // ref, without anybody having to know which branch it was. func (c Context) RawURL(ownerRepo, pathInRepo string) (string, bool) { switch c.flavor() { case "gitea": return fmt.Sprintf("https://%s/%s/raw/%s", c.Host, ownerRepo, pathInRepo), true case "github": return fmt.Sprintf("https://raw.githubusercontent.com/%s/%%s/%s", ownerRepo, pathInRepo), false case "gitlab": return fmt.Sprintf("https://%s/%s/-/raw/%%s/%s", c.Host, ownerRepo, pathInRepo), false } return "", false } // CloneURL is what git is pointed at. func (c Context) CloneURL(ownerRepo string) string { return fmt.Sprintf("https://%s/%s.git", c.Host, ownerRepo) } func (c Context) flavor() string { if c.Flavor == "" { return "gitea" } return strings.ToLower(c.Flavor) } // Flavors names what RawURL understands, for error messages. func Flavors() string { return "gitea, github, gitlab" }