f18a633185
The plugin required `tea`, Gitea's own CLI, for everything that is not an issue: releases, pull requests, milestones, branches, actions, webhooks. That put a second binary, a second set of logins nothing here could see, and 400 lines documenting somebody else's flags outside anything this repository can test. One command over the transport that already existed removes all three. Transport: `post` — the hand-rolled request the SDK cannot express, written for the dependency endpoint — is generalized to an exported `Do`, and `post` is three lines on top of it. Same http.Client, so the same RoundTripper files the body under .kettle/payload/, the same `token …` header authenticates it, and a non-2xx is the same *APIError. It does not paginate, does not reformat the answer, and names no domain concept, so the layering test is untouched. The endpoint rule is `tea api`'s, so an endpoint table written for that tool still works — with one restriction it did not have: a full URL must be on this instance. Every request carries the project's token in a header, and a URL on another host would hand the token to whatever was typed. Command: `kettle api <endpoint>` in a new `api` group, so the generator writes plugins/kettle/skills/api/SKILL.md — group, directory and /kettle:api are one word. No --repo and no --login, for the reason no sync command has them: a cross-repository address is an address, and another instance is KETTLE_URL. `-X DELETE` needs `--yes`; a flag typed on purpose is an operator's decision. Scopes: a token minted for issues carries write:issue and answers 403 on the first request outside issues, naming no scope. Gitea cannot be asked what a token may do — its own token listing needs a password — so `auth add --scopes` records it, `auth list` and `config` show it, and a 403 says which category it is likely to be. Documentation only; nothing is checked against it. skills/use — the tea reference, 239 lines of it — becomes skills/api: what to ask for, which endpoints paginate, and how to write a body. Every mention of `tea` as a requirement is gone from the manifests, the READMEs, the runner and the four other skills; what survives is the back-compat with the old plugin, which is a decision and not a debt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
382 lines
12 KiB
Go
382 lines
12 KiB
Go
// Package config holds the two files kettle reads: what this project is, and
|
|
// who this machine is.
|
|
//
|
|
// The split is the whole design. `<project>/.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 `<project>/.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"`
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|