diff --git a/.loom/event-log.md b/.loom/event-log.md index da6238a..14f19c2 100644 --- a/.loom/event-log.md +++ b/.loom/event-log.md @@ -837,3 +837,41 @@ hours.** *The warning now names both exits, including the one we have not built. **Telling somebody a rule and not the way out of it is how a guardrail becomes something people route around.*** + +## 2026-09-07 — measured: only gitea resolves its own short raw URL `rowan` + +**A lock must record a resolved URL** — *a short form follows whatever the default +branch is at the time you ask, so a branch rename reports as a change in the +document.* **Adopting by name means building that URL, and the three hosts do not +behave alike:** + +``` +gitea /{owner}/{repo}/raw/{path} 303 -> /raw/branch/main/{path} resolved +github raw.githubusercontent.com/{o}/{r}/HEAD/{path} 200, no redirect NOT resolved +gitlab /{owner}/{repo}/-/raw/HEAD/{path} 200, no redirect NOT resolved +``` + +**`HEAD` works on all three and resolves on none.** *It would put a moving ref in +the lock, which is the hazard we recorded and then nearly built.* + +**So gitea's redirect does the work, and the other two need the branch resolved +first** — *`git ls-remote --symref HEAD`, which is one round trip and no +clone.* + +**Belief that could be shown wrong:** *that gitea keeps redirecting.* **The short +form is a convenience, not a documented interface**, *and if it stops we resolve +the branch the same way as everybody else.* + +## 2026-09-07 — supersedes "git is needed for `list` only" `rowan` + +**Adopting by name against github or gitlab needs `git ls-remote` to resolve the +default branch.** *Against gitea it does not, because the redirect answers the +same question.* + +**So the earlier claim — `list` is the only command that needs `git`, which makes +`list` the seam — is now wrong for two of three flavors.** *The seam is unchanged +in practice here, where everything is gitea, and the entry would otherwise read as +still true.* + +*Recorded because the code changed in a way that quietly moved a boundary somebody +might rely on.* diff --git a/internal/config/config.go b/internal/config/config.go index 2487c64..7c518fd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,9 +1,12 @@ // 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". +// 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 ( @@ -16,15 +19,19 @@ import ( "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. +// 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 { - Hosts map[string]Host `json:"hosts"` + CurrentContext string `json:"current-context"` + Contexts map[string]Context `json:"contexts"` } // Path is where the config lives. Never inside a repository. @@ -39,10 +46,10 @@ func Path() string { 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. +// 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{Hosts: map[string]Host{}} + c := &Config{Contexts: map[string]Context{}} p := Path() if p == "" { return c, nil @@ -57,16 +64,31 @@ func Load() (*Config, error) { 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{} + 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. +// 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 @@ -74,11 +96,48 @@ func (c *Config) TokenFor(host string) string { if t := os.Getenv("LOOMCTL_TOKEN"); t != "" { return t } - return c.Hosts[host].Token + for _, ctx := range c.Contexts { + if ctx.Host == host && ctx.Token != "" { + return ctx.Token + } + } + return "" } -// 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)) } + +// 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" } diff --git a/internal/external/byname.go b/internal/external/byname.go new file mode 100644 index 0000000..502a5a3 --- /dev/null +++ b/internal/external/byname.go @@ -0,0 +1,86 @@ +package external + +import ( + "bytes" + "fmt" + "io" + "os/exec" + "strings" + + "git.hypertheory-labs.dev/loom/loom-cli/internal/config" +) + +// AddByName adopts a document named the way `list` prints it, against the +// current context. +// +// loomctl external add loom/cart cart +// +// Nobody should have to type a host's raw-file route to adopt a document. The +// route belongs to the host, so it lives in the context; the published +// directory belongs to the convention, so it lives in the code; and what is +// left — which repository, which document — is the only part a person knows. +func AddByName(root, ownerRepo, name string, out io.Writer) error { + cfg, err := config.Load() + if err != nil { + return err + } + ctx, err := cfg.Current() + if err != nil { + return err + } + if strings.Count(ownerRepo, "/") != 1 { + return fmt.Errorf("want owner/repo, got %q", ownerRepo) + } + if !strings.HasSuffix(name, ".md") { + name += ".md" + } + + raw, resolved := ctx.RawURL(ownerRepo, PublishedDir+"/"+name) + if raw == "" { + return fmt.Errorf("context %q has flavor %q; known flavors are %s", + cfg.CurrentContext, ctx.Flavor, config.Flavors()) + } + if !resolved { + // The lock must record a resolved URL. A short form that stays short + // follows whatever the default branch is at the time you ask, so a + // branch rename would report as a change in the document. + branch, err := defaultBranch(ctx.CloneURL(ownerRepo)) + if err != nil { + return fmt.Errorf("resolving the default branch of %s: %w", ownerRepo, err) + } + raw = fmt.Sprintf(raw, branch) + } + return Add(root, raw, "", out) +} + +// ListByName enumerates a repository named against the current context. +func ListByName(ownerRepo string, out io.Writer) error { + cfg, err := config.Load() + if err != nil { + return err + } + ctx, err := cfg.Current() + if err != nil { + return err + } + return List(ctx.CloneURL(ownerRepo), out) +} + +// defaultBranch asks the remote which branch HEAD points at, without cloning. +// +// Only hosts whose raw URLs do not redirect need this. Gitea resolves its own +// short form, which is why adopting from gitea needs no git at all. +func defaultBranch(cloneURL string) (string, error) { + cmd := exec.Command("git", "ls-remote", "--symref", cloneURL, "HEAD") + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) + } + for _, line := range strings.Split(stdout.String(), "\n") { + if after, ok := strings.CutPrefix(line, "ref: refs/heads/"); ok { + return strings.TrimSpace(strings.SplitN(after, "\t", 2)[0]), nil + } + } + return "", fmt.Errorf("no symref in git ls-remote output") +} diff --git a/main.go b/main.go index 9b361ed..120147e 100644 --- a/main.go +++ b/main.go @@ -9,15 +9,21 @@ package main import ( "flag" "fmt" + "io" "os" + "sort" + "strings" + + "git.hypertheory-labs.dev/loom/loom-cli/internal/config" "git.hypertheory-labs.dev/loom/loom-cli/internal/external" ) const usage = `loomctl — fetch what you depend on, and find out when it changed. - loomctl external list what a repository publishes - loomctl external add [--path p] adopt one document and lock it + loomctl external list what a repository publishes + loomctl external add adopt one document and lock it + loomctl external add [--path p] ... or by its full raw URL loomctl external check ask every publisher whether theirs moved loomctl external apply [path...] move a staged polad into place, lock and all @@ -41,9 +47,24 @@ Adopted documents live in .loom/externals////.md, and their origins in .loom/externals/.locks. The path is for a person to read; the lock is what a machine uses, because the path does not round-trip to a URL. -Credentials are read-only and per host, in ~/.config/loomctl/config.json or in -LOOMCTL_TOKEN_. loomctl never writes over the network, so a token it is -given should never carry write scope. + loomctl config which context is current, and from where + +A bare owner/repo is resolved against the current context in +~/.config/loomctl/config.json, which holds a host, its flavor and a read-only +token. The host's raw-file route lives there so that nobody has to type it, and +LOOMCTL_TOKEN_ overrides the file for one invocation. loomctl never writes +over the network, so a token it is given should never carry write scope. + + { + "current-context": "hypertheory", + "contexts": { + "hypertheory": { + "host": "git.hypertheory-labs.dev", + "flavor": "gitea", + "token": "..." + } + } + } Requires git on PATH, for list only. ` @@ -63,6 +84,8 @@ func run(args []string) error { switch args[0] { case "external": return runExternal(args[1:]) + case "config": + return showConfig(os.Stdout) default: return fmt.Errorf("unknown command %q\n\n%s", args[0], usage) } @@ -75,9 +98,12 @@ func runExternal(args []string) error { switch args[0] { case "list": if len(args) != 2 { - return fmt.Errorf("usage: loomctl external list ") + return fmt.Errorf("usage: loomctl external list ") } - return external.List(args[1], os.Stdout) + if strings.Contains(args[1], "://") { + return external.List(args[1], os.Stdout) + } + return external.ListByName(args[1], os.Stdout) case "add": fs := flag.NewFlagSet("add", flag.ContinueOnError) @@ -85,14 +111,23 @@ func runExternal(args []string) error { if err := fs.Parse(args[1:]); err != nil { return err } - if fs.NArg() != 1 { - return fmt.Errorf("usage: loomctl external add [--path p]") - } root, err := root() if err != nil { return err } - return external.Add(root, fs.Arg(0), *path, os.Stdout) + switch fs.NArg() { + case 1: + if !strings.Contains(fs.Arg(0), "://") { + return fmt.Errorf("adopting by name needs the document too: " + + "loomctl external add ") + } + return external.Add(root, fs.Arg(0), *path, os.Stdout) + case 2: + return external.AddByName(root, fs.Arg(0), fs.Arg(1), os.Stdout) + default: + return fmt.Errorf("usage: loomctl external add , " + + "or loomctl external add [--path p]") + } case "check": root, err := root() @@ -113,6 +148,54 @@ func runExternal(args []string) error { } } +// showConfig says which context is current and where it came from, so that "my +// token is not being used" is answerable without printing the token. +func showConfig(out io.Writer) error { + cfg, err := config.Load() + if err != nil { + return err + } + fmt.Fprintf(out, "config %s\n", config.Path()) + if len(cfg.Contexts) == 0 { + fmt.Fprintln(out, " (none — public repositories still work by full URL)") + return nil + } + names := make([]string, 0, len(cfg.Contexts)) + for n := range cfg.Contexts { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + c := cfg.Contexts[n] + marker := " " + if n == cfg.CurrentContext { + marker = "*" + } + flavor := c.Flavor + if flavor == "" { + flavor = "gitea (default)" + } + fmt.Fprintf(out, "%s %-14s %-32s %-16s %s\n", marker, n, c.Host, flavor, credential(cfg, c)) + } + if _, err := cfg.Current(); err != nil { + fmt.Fprintf(out, "\n%v\n", err) + } + return nil +} + +func credential(cfg *config.Config, c config.Context) string { + if os.Getenv("LOOMCTL_TOKEN") != "" { + return "token from LOOMCTL_TOKEN" + } + if cfg.TokenFor(c.Host) != "" { + if c.Token == "" { + return "token from the environment" + } + return "token set" + } + return "no token (anonymous)" +} + func root() (string, error) { wd, err := os.Getwd() if err != nil {