package main
import (
"encoding/json"
"flag"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// repoCmd is the client-side verb for managing repositories. Unlike
// `account` and `user`, which open the forge database directly, this
// talks to a RUNNING server over the API — because registering a
// repository is not a config edit, it is a live operation: the server
// opens an index, starts a pipeline and begins serving. Two processes
// writing that registry would disagree about which repositories exist.
//
// It is therefore also the first proof that the API is sufficient: the
// CLI has no privileged path, so anything it can do, any client can.
func repoCmd(args []string) error {
if len(args) < 1 {
return fmt.Errorf("usage: quarry repo new|clone|list|log|describe|private|public|remove|delete [flags]")
}
verb, rest := args[0], args[1:]
fs := flag.NewFlagSet("repo "+verb, flag.ContinueOnError)
server := fs.String("server", envOr("QUARRY_SERVER", "http://127.0.0.1:8080"), "quarry base URL")
user := fs.String("user", envOr("QUARRY_USER", ""), "admin account")
password := fs.String("password", "", "password (or QUARRY_PASSWORD)")
name := fs.String("name", "", "repository name")
path := fs.String("path", "", "store directory (default: the server's repositories root)")
desc := fs.String("description", "", "one-line summary")
from := fs.String("from", "", "clone this URL")
every := fs.Int("every", 0, "mirror interval in seconds (clone only; 0 = server default)")
noMirror := fs.Bool("no-mirror", false, "do not record a mirror when cloning")
private := fs.Bool("private", false, "only accounts with a role may see it")
readme := fs.Bool("readme", false, "seed a README and make the first check-in")
license := fs.String("license", "", "seed a license: mit or bsd-2")
ignore := fs.Bool("ignore", false, "seed a starter .zitignore")
confirm := fs.String("confirm", "", "for delete: type the repository's name")
limit := fs.Int("limit", 50, "for log: how many entries")
_ = fs.Bool("public", false, "make an existing repository public again") // verb form is used
if err := fs.Parse(rest); err != nil {
return err
}
if r := fs.Args(); len(r) > 0 {
return fmt.Errorf("unexpected argument %q", r[0])
}
pw := *password
if pw == "" {
pw = os.Getenv("QUARRY_PASSWORD")
}
c := &apiClient{base: strings.TrimRight(*server, "/"), user: *user, pass: pw}
switch verb {
case "log":
// Read over the API like every other verb here: the CLI has no
// privileged path to the registry, so what it can read, any
// client can. Opening the database directly would also mean
// reading a file the running server is writing.
if *name == "" {
return fmt.Errorf("usage: quarry repo log --name N [--limit N]")
}
var out struct {
Count int `json:"count"`
Changes []struct {
At, Actor, Field, Was, Now string
} `json:"changes"`
}
if err := c.call("GET", "/api/v1/r/"+url.PathEscape(*name)+
"/settings/log?limit="+fmt.Sprint(*limit), nil, &out); err != nil {
return err
}
if out.Count == 0 {
// Said plainly, because the absence means one specific thing:
// no FIELD has changed. It does not mean nobody saved -- a
// save that changes nothing records nothing.
fmt.Println("no settings have changed on this repository")
return nil
}
for _, e := range out.Changes {
who := e.Actor
if who == "" {
who = "(unidentified)"
}
switch {
case e.Was == "":
fmt.Printf("%s %-12s %-14s set to %s\n", e.At, who, e.Field, e.Now)
case e.Now == "":
fmt.Printf("%s %-12s %-14s cleared, was %s\n", e.At, who, e.Field, e.Was)
default:
fmt.Printf("%s %-12s %-14s %s -> %s\n", e.At, who, e.Field, e.Was, e.Now)
}
}
return nil
case "list":
var out struct {
Repos []struct {
Name, Description string
Checkins int
Private bool
} `json:"repos"`
}
if err := c.call("GET", "/api/v1/repos", nil, &out); err != nil {
return err
}
for _, r := range out.Repos {
mark := " "
if r.Private {
mark = "*"
}
fmt.Printf("%s%-23s %6d %s\n", mark, r.Name, r.Checkins, r.Description)
}
return nil
case "new", "clone":
if *name == "" {
return fmt.Errorf("repo %s wants --name", verb)
}
if verb == "clone" && *from == "" {
return fmt.Errorf("repo clone wants --from URL")
}
body := map[string]any{
"name": *name, "path": *path, "description": *desc,
"create": verb == "new", "clone": *from,
"noMirror": *noMirror, "mirrorEvery": *every, "private": *private,
"readme": *readme, "license": *license, "ignore": *ignore,
}
var out struct {
Name, Path string
Mirrored bool
}
if err := c.call("POST", "/api/v1/repos", body, &out); err != nil {
return err
}
fmt.Printf("%s created at %s\n", out.Name, out.Path)
if out.Mirrored {
fmt.Printf("mirroring %s\n", *from)
}
fmt.Printf("\n zit clone %s/r/%s %s\n\n", c.base, out.Name, out.Name)
return nil
case "private", "public":
if *name == "" {
return fmt.Errorf("repo %s wants --name", verb)
}
// Read the current settings first: update applies fields whole,
// so sending only visibility would blank the description.
cur, err := c.repoSettings(*name)
if err != nil {
return err
}
cur["private"] = verb == "private"
if err := c.call("POST", "/api/v1/repos/update", cur, nil); err != nil {
return err
}
fmt.Printf("%s is now %s\n", *name, verb)
return nil
case "describe":
if *name == "" {
return fmt.Errorf("repo describe wants --name")
}
cur, err := c.repoSettings(*name)
if err != nil {
return err
}
cur["description"] = *desc
if err := c.call("POST", "/api/v1/repos/update", cur, nil); err != nil {
return err
}
fmt.Printf("%s: %s\n", *name, *desc)
return nil
case "delete":
if *name == "" {
return fmt.Errorf("repo delete wants --name and --confirm")
}
if err := c.call("POST", "/api/v1/repos/delete",
map[string]any{"name": *name, "confirm": *confirm}, nil); err != nil {
return err
}
fmt.Printf("%s destroyed\n", *name)
return nil
case "remove":
if *name == "" {
return fmt.Errorf("repo remove wants --name")
}
if err := c.call("POST", "/api/v1/repos/remove", map[string]any{"name": *name}, nil); err != nil {
return err
}
fmt.Printf("%s is no longer served (its store on disk is untouched)\n", *name)
return nil
}
return fmt.Errorf("unknown verb %q: want new|clone|list|describe|private|public|remove|delete", verb)
}
// accountAPICmd manages accounts through the API rather than the
// database. `quarry account` still opens OLTP directly — it has to, so
// the FIRST account can be created before any server is running or any
// admin exists to authenticate as. Past that, the API is the surface,
// and driving it from here is what keeps it honest.
func accountAPICmd(args []string) error {
if len(args) < 1 {
return fmt.Errorf("usage: quarry accounts list|add|reset|grant|revoke|delete [flags]")
}
verb, rest := args[0], args[1:]
fs := flag.NewFlagSet("accounts "+verb, flag.ContinueOnError)
server := fs.String("server", envOr("QUARRY_SERVER", "http://127.0.0.1:8080"), "quarry base URL")
as := fs.String("as", envOr("QUARRY_USER", ""), "admin account to act as")
asPassword := fs.String("as-password", "", "that account's password (or QUARRY_PASSWORD)")
name := fs.String("user", "", "the account to operate on")
password := fs.String("password", "", "the new account's password, for add (or QUARRY_NEW_PASSWORD)")
admin := fs.Bool("admin", false, "make a site admin")
repo := fs.String("repo", "", "repository, for grant and revoke")
role := fs.String("role", "reader", "reader|developer|maintainer|admin")
if err := fs.Parse(rest); err != nil {
return err
}
if r := fs.Args(); len(r) > 0 {
return fmt.Errorf("unexpected argument %q", r[0])
}
c := &apiClient{base: strings.TrimRight(*server, "/"), user: *as,
pass: firstNonEmpty(*asPassword, os.Getenv("QUARRY_PASSWORD"))}
switch verb {
case "list":
var out struct {
Accounts []struct {
Name string
Admin bool
Created string
Grants []struct{ Repo, Role string }
}
}
if err := c.call("GET", "/api/v1/accounts", nil, &out); err != nil {
return err
}
for _, a := range out.Accounts {
kind := ""
if a.Admin {
kind = " (site admin)"
}
fmt.Printf("%-24s%s\n", a.Name, kind)
for _, g := range a.Grants {
fmt.Printf(" %-20s %s\n", g.Repo, g.Role)
}
}
return nil
case "add":
if *name == "" {
return fmt.Errorf("accounts add wants --user")
}
pw := firstNonEmpty(*password, os.Getenv("QUARRY_NEW_PASSWORD"))
if pw == "" {
return fmt.Errorf("no password: use --password or QUARRY_NEW_PASSWORD")
}
body := map[string]any{"name": *name, "password": pw, "admin": *admin}
if err := c.call("POST", "/api/v1/accounts", body, nil); err != nil {
return err
}
fmt.Printf("%s: password set\n", *name)
return nil
// No passwd: the forge sets nobody else's password (#319). The
// owner is mailed a link; an account with no confirmed address is
// recovered on the box with `quarry account passwd`.
case "reset":
if *name == "" {
return fmt.Errorf("accounts reset wants --user")
}
if err := c.call("POST", "/api/v1/accounts/reset", map[string]any{"name": *name}, nil); err != nil {
return err
}
fmt.Printf("%s: a reset link went to their own address\n", *name)
return nil
case "grant", "revoke":
if *name == "" || *repo == "" {
return fmt.Errorf("accounts %s wants --user and --repo", verb)
}
body := map[string]any{"name": *name, "repo": *repo}
if verb == "grant" {
body["role"] = *role
}
if err := c.call("POST", "/api/v1/accounts/"+verb, body, nil); err != nil {
return err
}
if verb == "grant" {
fmt.Printf("%s: %s on %s\n", *name, *role, *repo)
} else {
fmt.Printf("%s: no access to %s\n", *name, *repo)
}
return nil
case "delete":
if *name == "" {
return fmt.Errorf("accounts delete wants --user")
}
if err := c.call("POST", "/api/v1/accounts/delete", map[string]any{"name": *name}, nil); err != nil {
return err
}
fmt.Printf("%s deleted\n", *name)
return nil
}
return fmt.Errorf("unknown verb %q: want list|add|reset|grant|revoke|delete", verb)
}
// repoSettings reads a repository's current settings as an update body,
// so a caller changing one field does not silently blank the others.
func (c *apiClient) repoSettings(name string) (map[string]any, error) {
var info struct {
Name, Description string
Private bool
}
if err := c.call("GET", "/api/v1/r/"+name+"/info", nil, &info); err != nil {
return nil, err
}
return map[string]any{"name": info.Name, "description": info.Description,
"private": info.Private}, nil
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// apiClient is the smallest thing that can drive quarry's API with an
// admin credential. Basic auth because that is what the API already
// takes (0001 §7: one credential per user).
type apiClient struct {
base, user, pass string
}
func (c *apiClient) call(method, path string, body any, out any) error {
var rdr *strings.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return err
}
rdr = strings.NewReader(string(raw))
}
var req *http.Request
var err error
if rdr != nil {
req, err = http.NewRequest(method, c.base+path, rdr)
} else {
req, err = http.NewRequest(method, c.base+path, nil)
}
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if c.user != "" {
req.SetBasicAuth(c.user, c.pass)
}
// Generous: a clone of a large repository is minutes, and the server
// holds the request open for it.
resp, err := (&http.Client{Timeout: 30 * time.Minute}).Do(req)
if err != nil {
return fmt.Errorf("%s: %w", urlOf(c.base, path), err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
var e struct {
Error string `json:"error"`
}
json.NewDecoder(resp.Body).Decode(&e)
if e.Error == "" {
e.Error = resp.Status
}
return fmt.Errorf("%s", e.Error)
}
if out == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
func urlOf(base, path string) string {
u, err := url.Parse(base + path)
if err != nil {
return base + path
}
return u.String()
}