Generates .loom/orientation.md — what this repository depends on, where each copy came from, and which facets sit beside it — for whoever arrives next, of any make. One file rather than two, decided by loom's own rule. Generate what varies, adopt what does not: the publishing half varies not at all and is already adopted, so publication.md appears in the index like any other adopted document, in exactly the repositories that adopted it. A second command would emit a file whose whole content is a pointer to a file already in the tree, and would revive a noun declined when published check went. Restates no rule. The three moves are phrased as operations — what to run, what to write, where — and every rule stays in the document that owns it. The .usages.md is pointed at rather than summarised, because what depends on a document is free prose and anything extracting a claim from prose eventually extracts it wrong. It says nothing recorded when a document has no usages file, which is a finding rather than an omission and is currently true of six of nine. Byte-deterministic, pinned by a test that inserts records out of order and generates three times. Written to .loom/orientation.md rather than inside .loom/externals/, where check would report it as an unlocked external in every repository using the feature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
227 lines
6.8 KiB
Go
227 lines
6.8 KiB
Go
// loomctl fetches documents this repository depends on, and finds out when they
|
|
// change.
|
|
//
|
|
// It reports and never repairs. Everything it writes, it writes to the working
|
|
// tree — committing and pushing are yours, because the consequences of a push
|
|
// land on people a tool cannot experience.
|
|
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"
|
|
"git.hypertheory-labs.dev/loom/loom-cli/internal/orient"
|
|
)
|
|
|
|
const usage = `loomctl — fetch what you depend on, and find out when it changed.
|
|
|
|
loomctl external list <owner/repo> what a repository publishes
|
|
loomctl external add <owner/repo> <doc> adopt one document and lock it
|
|
loomctl external add <url> [--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
|
|
|
|
check reports and does not fix. A document that moved is staged as a polad in
|
|
.loom/cart/current/polad/ — a candidate shaped exactly like what it would
|
|
become — and somebody decides. Its exits are apply or discard; nothing there
|
|
drifts into being kept. With no cart open, check says what moved and stages
|
|
nothing, because opening a round is somebody's act and not a side effect.
|
|
|
|
loomctl grants no access. It reads what your credentials already let you read,
|
|
and records where the copy came from — which is the part copy-and-paste loses.
|
|
It warns rather than refuses: adopting from a source less readable than the
|
|
repository you are adopting into is yours to decide, and the check only tells
|
|
public from not-public, so it cannot see two repositories private to different
|
|
people.
|
|
|
|
add adopts what is not here yet, and refuses what is already adopted. Given a
|
|
document that is present but unlocked — fetched by hand before this existed —
|
|
it supplies the missing origin: identical bytes lock it, differing bytes are
|
|
staged, and the local copy is never overwritten, because a copy that differs is
|
|
the only evidence that anything moved while nothing was watching.
|
|
|
|
Adopted documents live in .loom/externals/<host>/<owner>/<repo>/<name>.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.
|
|
|
|
loomctl orient [--out path] write .loom/orientation.md for whoever arrives next
|
|
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_<HOST> 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.
|
|
`
|
|
|
|
func main() {
|
|
if err := run(os.Args[1:]); err != nil {
|
|
fmt.Fprintln(os.Stderr, "loomctl: "+err.Error())
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run(args []string) error {
|
|
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
|
|
fmt.Print(usage)
|
|
return nil
|
|
}
|
|
switch args[0] {
|
|
case "external":
|
|
return runExternal(args[1:])
|
|
case "orient":
|
|
fs := flag.NewFlagSet("orient", flag.ContinueOnError)
|
|
out := fs.String("out", "", "write here instead of "+orient.File)
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return err
|
|
}
|
|
root, err := root()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dest, err := orient.Generate(root, *out)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(os.Stdout, "wrote %s\n", dest)
|
|
return nil
|
|
case "config":
|
|
return showConfig(os.Stdout)
|
|
default:
|
|
return fmt.Errorf("unknown command %q\n\n%s", args[0], usage)
|
|
}
|
|
}
|
|
|
|
func runExternal(args []string) error {
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("external needs a subcommand: list, add, check, apply")
|
|
}
|
|
switch args[0] {
|
|
case "list":
|
|
if len(args) != 2 {
|
|
return fmt.Errorf("usage: loomctl external list <owner/repo>")
|
|
}
|
|
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)
|
|
path := fs.String("path", "", "where to keep it, relative to .loom/externals (default: derived from the URL)")
|
|
if err := fs.Parse(args[1:]); err != nil {
|
|
return err
|
|
}
|
|
root, err := root()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch fs.NArg() {
|
|
case 1:
|
|
if !strings.Contains(fs.Arg(0), "://") {
|
|
return fmt.Errorf("adopting by name needs the document too: " +
|
|
"loomctl external add <owner/repo> <doc>")
|
|
}
|
|
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 <owner/repo> <doc>, " +
|
|
"or loomctl external add <url> [--path p]")
|
|
}
|
|
|
|
case "check":
|
|
root, err := root()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return external.Check(root, os.Stdout)
|
|
|
|
case "apply":
|
|
root, err := root()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return external.Apply(root, args[1:], os.Stdout)
|
|
|
|
default:
|
|
return fmt.Errorf("unknown external subcommand %q", args[0])
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
return "", err
|
|
}
|
|
return external.FindRoot(wd)
|
|
}
|