Skip to content
quarry Artifact [48dc218ff2]
Login

Artifact [48dc218ff2]

Artifact 48dc218ff254eef815935c40de9f174ca4cec9ee79ea855aa946ab3fc77a1a69:


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|passwd|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", "", "new password (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", "passwd":
		if *name == "" {
			return fmt.Errorf("accounts %s wants --user", verb)
		}
		pw := firstNonEmpty(*password, os.Getenv("QUARRY_NEW_PASSWORD"))
		if pw == "" {
			return fmt.Errorf("no password: use --password or QUARRY_NEW_PASSWORD")
		}
		path, body := "/api/v1/accounts", map[string]any{"name": *name, "password": pw, "admin": *admin}
		if verb == "passwd" {
			path = "/api/v1/accounts/passwd"
			body = map[string]any{"name": *name, "password": pw}
		}
		if err := c.call("POST", path, body, nil); err != nil {
			return err
		}
		fmt.Printf("%s: password set\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|passwd|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()
}