// Package config holds the two files kettle reads: what this project is, and // who this machine is. // // The split is the whole design. `/.kettle/config.yaml` says which // tracker repository the issues belong to and which login to reach it under — // facts about the project, written by `kettle init`. The credentials themselves // live in one file per machine, outside any repository, mode 0600. // // A token in a file inside a working tree ends up in a commit. Not always, not // immediately, and not by anyone careless — but a project config is exactly the // file somebody eventually decides to share, and a secret that has ever been // pushed is a secret that has to be rotated. So the project pins a login by // NAME and the name is worth nothing on its own. package config import ( "errors" "fmt" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" "git.noodles.cam/claude-skills/marketplace/cli/internal/project" ) // Environment overrides, each winning over the file it shadows. They exist for // CI, for a one-off run against another instance, and for anyone who would // rather not have a token on disk at all. const ( EnvLogin = "KETTLE_LOGIN" EnvURL = "KETTLE_URL" EnvToken = "KETTLE_TOKEN" EnvRepo = "KETTLE_REPO" // EnvHome relocates the machine-wide login file; the test suite sets it so // a run can never read or write the developer's own. EnvHome = "KETTLE_CONFIG_HOME" ) const projectHeader = `# kettle — project configuration # # login the name of a login in the machine-wide file, NOT a credential. # Manage those with ` + "`kettle auth`" + `; they live outside this tree. # repo the tracker repository these issues belong to, as owner/name. # # Overrides, when you need one: ` + EnvLogin + `, ` + EnvRepo + `, ` + EnvURL + `, ` + EnvToken + `. ` // Project is `/.kettle/config.yaml`. type Project struct { // Login names an entry in the machine-wide login file. Never a token. Login string `yaml:"login"` // Repo is the tracker repository, as owner/name. Repo string `yaml:"repo"` } // Login is one set of credentials for one Gitea instance. type Login struct { Name string `yaml:"name"` URL string `yaml:"url"` User string `yaml:"user,omitempty"` Token string `yaml:"token"` } // Logins is the machine-wide file. type Logins struct { Logins []Login `yaml:"logins"` } // ErrNoConfig means the project has no config.yaml yet. var ErrNoConfig = errors.New("no project configuration") // ProjectPath is where this project's config.yaml is, or "" with no project. func ProjectPath(start string) string { return project.ConfigPath(start) } // LoadProject reads the project configuration. // // A missing file is ErrNoConfig, not an empty config: "this project has not // been told which tracker it belongs to" and "it belongs to no tracker" are // different answers and only one of them is fixable by running init. func LoadProject(start string) (*Project, error) { path := ProjectPath(start) if path == "" { return nil, project.NotFoundError(start) } raw, err := os.ReadFile(path) if os.IsNotExist(err) { return nil, fmt.Errorf("%w at %s — run `kettle init` there", ErrNoConfig, path) } if err != nil { return nil, err } var p Project if err := strictUnmarshal(raw, &p); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } return &p, nil } // ReadProjectFile reads a config.yaml at a path already known, reporting // whether the file was there. // // LoadProject resolves the path by walking for a marker, which is the right // thing everywhere except inside `kettle init` — the command that is creating // the marker, and on a dry run may not have created it at all. func ReadProjectFile(path string) (*Project, bool, error) { raw, err := os.ReadFile(path) if os.IsNotExist(err) { return &Project{}, false, nil } if err != nil { return nil, false, err } var p Project if err := strictUnmarshal(raw, &p); err != nil { return nil, true, fmt.Errorf("%s: %w", path, err) } return &p, true, nil } // SaveProject writes the project configuration, header comment and all. func SaveProject(path string, p *Project) error { body, err := yaml.Marshal(p) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } return os.WriteFile(path, append([]byte(projectHeader+"\n"), body...), 0o644) } // LoginsPath is the machine-wide login file. // // One file per machine, deliberately outside every working tree: which tokens // this computer holds is a fact about the computer, the way which issues a tree // holds is a fact about the tree. func LoginsPath() string { if h := os.Getenv(EnvHome); h != "" { return filepath.Join(h, "logins.yaml") } if x := os.Getenv("XDG_CONFIG_HOME"); x != "" { return filepath.Join(x, "kettle", "logins.yaml") } home, err := os.UserHomeDir() if err != nil { return "" } return filepath.Join(home, ".config", "kettle", "logins.yaml") } // LoadLogins reads the machine-wide login file. A missing file is an empty // list, not an error: a machine with no logins yet is an ordinary machine. func LoadLogins() (*Logins, error) { path := LoginsPath() if path == "" { return &Logins{}, nil } raw, err := os.ReadFile(path) if os.IsNotExist(err) { return &Logins{}, nil } if err != nil { return nil, err } var l Logins if err := strictUnmarshal(raw, &l); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } return &l, nil } // SaveLogins writes the machine-wide login file with 0600, and creates its // directory with 0700. The file holds bearer tokens; nothing else on the // machine has any business reading it. func SaveLogins(l *Logins) error { path := LoginsPath() if path == "" { return errors.New("cannot locate a home directory for the login file — set " + EnvHome) } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } body, err := yaml.Marshal(l) if err != nil { return err } return os.WriteFile(path, body, 0o600) } // Find returns the login with this name. func (l *Logins) Find(name string) *Login { for i := range l.Logins { if l.Logins[i].Name == name { return &l.Logins[i] } } return nil } // Names lists every login on this machine, for an error message that can // actually be acted on. func (l *Logins) Names() []string { out := make([]string, 0, len(l.Logins)) for _, e := range l.Logins { out = append(out, e.Name) } return out } // Resolved is everything the transport needs, with every override applied. type Resolved struct { Login string URL string Token string Owner string Repo string } // Slug is owner/name, the way a tracker writes it. func (r *Resolved) Slug() string { return r.Owner + "/" + r.Repo } // Redacted is the same thing with the token replaced, for printing. func (r *Resolved) Redacted() Resolved { out := *r if out.Token != "" { out.Token = "(set)" } return out } // Resolve merges the project config, the machine's login file, and the // environment into what the transport needs. // // Every failure names the file it read and the command that fixes it. "401 // Unauthorized" is what happens when this function is allowed to return a // half-filled struct. func Resolve(start string) (*Resolved, error) { var p Project if loaded, err := LoadProject(start); err == nil { p = *loaded } else if !errors.Is(err, ErrNoConfig) { return nil, err } out := &Resolved{Login: p.Login} if v := os.Getenv(EnvLogin); v != "" { out.Login = v } repo := p.Repo if v := os.Getenv(EnvRepo); v != "" { repo = v } if repo != "" { owner, name, ok := strings.Cut(repo, "/") if !ok || owner == "" || name == "" { return nil, fmt.Errorf("repo %q is not owner/name", repo) } out.Owner, out.Repo = owner, name } if out.Login != "" { logins, err := LoadLogins() if err != nil { return nil, err } entry := logins.Find(out.Login) if entry == nil { known := "none on this machine" if names := logins.Names(); len(names) > 0 { known = strings.Join(names, ", ") } return nil, fmt.Errorf("no login %q in %s — known: %s; add one with `kettle auth add`", out.Login, LoginsPath(), known) } out.URL, out.Token = entry.URL, entry.Token } if v := os.Getenv(EnvURL); v != "" { out.URL = v } if v := os.Getenv(EnvToken); v != "" { out.Token = v } out.URL = strings.TrimRight(out.URL, "/") return out, nil } // Require is Resolve plus the assertion that the result can actually reach a // tracker. func Require(start string) (*Resolved, error) { r, err := Resolve(start) if err != nil { return nil, err } var missing []string if r.URL == "" { missing = append(missing, "a URL (pin a login with `kettle init --login`, or set "+EnvURL+")") } if r.Token == "" { missing = append(missing, "a token (`kettle auth add`, or set "+EnvToken+")") } if r.Owner == "" { missing = append(missing, "a repository (`kettle init --repo owner/name`, or set "+EnvRepo+")") } if len(missing) > 0 { return nil, fmt.Errorf("this project has no %s", strings.Join(missing, ", and no ")) } return r, nil } // strictUnmarshal refuses keys the struct does not know. // // The alternative is silence: an older binary reading a newer config would drop // the setting it did not recognize, and rewriting the file would delete it. // Being told "unknown field" beats finding out later. func strictUnmarshal(raw []byte, out any) error { dec := yaml.NewDecoder(strings.NewReader(string(raw))) dec.KnownFields(true) if err := dec.Decode(out); err != nil && err.Error() != "EOF" { return err } return nil }