// 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 + `. ` const scaffoldHeader = `# kettle — what was last written into this project's agent-harness tree. # # Written by ` + "`kettle init`" + ` and ` + "`kettle gen scaffold`" + `; read by # ` + "`kettle config`" + `, which says so when the build that wrote the tree is not the # build that is installed now. Nothing resolves from this file — deleting it # costs the warning and nothing else. ` // 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"` } // Scaffold is `/.kettle/scaffold.yaml`: what `kettle` last wrote into // this project's agent-harness tree, and which build wrote it. // // A file of its own rather than two more keys in Project, and that is the rule // in "Unknown keys are an error" being obeyed rather than worked around: a field // added to config.yaml is a one-way door for a file that may be committed and // read by whatever version each machine happens to have. This one is written and // read by a single binary about a single directory, so it can carry that cost. // // It exists to answer one question — "were these documents written by the kettle // that is installed now?" — because the documents are generated whole and an // operator has no other way to tell a current tree from one four releases old. type Scaffold struct { // Version is what `kettle version` reported when the tree was written. A // hand build says `dev` and means it. Version string `yaml:"version"` // Out is where the tree went, relative to the project root when it is // underneath it. Out string `yaml:"out"` } // 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"` // Scopes is what the token was minted with, as Gitea spells it — // `write:issue`, `read:repository`. DOCUMENTATION ONLY, exactly like User: // nothing is checked against it and nothing is refused because of it. It is // written down because the instance will not say. `GET /user/tokens` needs // basic auth, not token auth, so a token cannot be asked what it may do — // and the failure that costs an afternoon is a 403 on a release from a token // somebody minted for issues a year ago. Scopes []string `yaml:"scopes,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) } // ScaffoldPath is where this project records what it last had written into it, // or "" with no project. func ScaffoldPath(start string) string { return project.ScaffoldPath(start) } // ReadScaffoldFile reads a scaffold record at a path already known, reporting // whether the file was there. // // A missing file is the zero value and not an error: a project initialized // before this record existed, or one written with --no-scaffold, has nothing to // say here and that is an ordinary state rather than a fault. func ReadScaffoldFile(path string) (*Scaffold, bool, error) { raw, err := os.ReadFile(path) if os.IsNotExist(err) { return &Scaffold{}, false, nil } if err != nil { return nil, false, err } var s Scaffold if err := strictUnmarshal(raw, &s); err != nil { return nil, true, fmt.Errorf("%s: %w", path, err) } return &s, true, nil } // SaveScaffoldFile writes the scaffold record. func SaveScaffoldFile(path string, s *Scaffold) error { body, err := yaml.Marshal(s) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } return os.WriteFile(path, append([]byte(scaffoldHeader+"\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 // Scopes is what the pinned login records its token was minted with. // Documentation, carried this far so `kettle config` can show it beside the // token it belongs to; nothing dials on it. A token out of the environment // records nothing, and an empty list means "not written down", never "none". Scopes []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 } return merge(p) } // ResolveOutsideAProject is Resolve for a caller that legitimately has no // project to stand in. // // `cmd/release` is the one, and it is not an exception being carved out: the // marker is gitignored, so a fresh clone has none, and a tool that publishes a // tag must not create one on its way past. With no marker there is nothing to // merge and the ENVIRONMENT IS the configuration — KETTLE_URL, KETTLE_TOKEN and // KETTLE_REPO, which is exactly what somebody exports before cutting a release. // // A marker that IS there is read as always, overrides and all, so the same // command run from a maintainer's own checkout picks up the login pinned in it // and needs no token in the shell. // // Every other caller wants Resolve: for `kettle`, "no project" is the answer, // not a state to work around. A push that quietly ran against whatever was in // the environment would be a push into somebody else's repository. func ResolveOutsideAProject(start string) (*Resolved, error) { if ProjectPath(start) == "" { return merge(Project{}) } return Resolve(start) } // merge applies the login file and the environment to a project's settings. func merge(p Project) (*Resolved, error) { 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, out.Scopes = entry.URL, entry.Token, entry.Scopes } 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 } if err := r.Complete(); err != nil { return nil, err } return r, nil } // Complete reports what a resolved configuration is still missing, naming the // one command or the one variable that supplies each. // // A half-filled struct allowed through is a 401 three calls later, and "401 // Unauthorized" names nothing an operator can act on. It is a method rather // than part of Resolve because the two questions are different: `kettle config` // wants to SHOW a half-filled configuration, and everything that dials wants to // refuse one. func (r *Resolved) Complete() error { 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 fmt.Errorf("this project has no %s", strings.Join(missing, ", and no ")) } return 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 }