// 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" ) const usage = `loomctl — fetch what you depend on, and find out when it changed. 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 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////.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 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. ` 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 "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 ") } 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 ") } 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() 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) }