▍ humdrum codex / custard v0.3.0
license AGPL-3.0

feat(cli): check / preview / promote verbs (TASK-007)

35016a1c02d1343840be71d5fb7ecd51fe49bd31
humdrum <me@humdrum.me> · 2026-06-18 07:58

parent 09469937

feat(cli): check / preview / promote verbs (TASK-007)

Local deploy client, Charm-styled (lipgloss) with a per-stage spinner:
- check: run .custard.yaml ci commands on the working tree (dirty ok)
- preview: require HEAD pushed → export HEAD (uncommitted edits excluded) →
  run ci on the export → run deploy.preview → parse the URL → save state
- promote: vercel promote the last preview (or a given url) → prod
- .custard.yaml gains ci: + deploy.preview/promote; release.LoadLocal reads it
- failing checks print captured output + exit 1; TTY-guarded spinner

Status reporting + forge badges and the release verb are next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

5 files changed

cmd/custard/main.go +8 −2
@@ -23,8 +23,14 @@ 	}
 	switch os.Args[1] {
 	case "serve":
 		cli.Serve(os.Args[2:])
-	case "check", "preview", "promote", "release":
-		fmt.Fprintf(os.Stderr, "custard %s: not implemented yet\n", os.Args[1])
+	case "check":
+		cli.Check(os.Args[2:])
+	case "preview":
+		cli.Preview(os.Args[2:])
+	case "promote":
+		cli.Promote(os.Args[2:])
+	case "release":
+		fmt.Fprintln(os.Stderr, "custard release: not implemented yet")
 		os.Exit(1)
 	case "-h", "--help", "help":
 		usage()
go.mod +11 −0
@@ -5,6 +5,7 @@
 require (
 	github.com/a-h/templ v0.3.1020
 	github.com/alecthomas/chroma/v2 v2.27.0
+	github.com/charmbracelet/lipgloss v1.1.0
 	github.com/go-git/go-git/v5 v5.19.1
 	github.com/yuin/goldmark v1.8.2
 	github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
@@ -16,6 +17,11 @@ require (
 	dario.cat/mergo v1.0.0 // indirect
 	github.com/Microsoft/go-winio v0.6.2 // indirect
 	github.com/ProtonMail/go-crypto v1.1.6 // indirect
+	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
+	github.com/charmbracelet/x/ansi v0.8.0 // indirect
+	github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
+	github.com/charmbracelet/x/term v0.2.1 // indirect
 	github.com/cloudflare/circl v1.6.3 // indirect
 	github.com/cyphar/filepath-securejoin v0.6.1 // indirect
 	github.com/dlclark/regexp2/v2 v2.2.1 // indirect
@@ -28,13 +34,18 @@ 	github.com/google/uuid v1.6.0 // indirect
 	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
 	github.com/kevinburke/ssh_config v1.2.0 // indirect
 	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mattn/go-runewidth v0.0.16 // indirect
+	github.com/muesli/termenv v0.16.0 // indirect
 	github.com/ncruces/go-strftime v1.0.0 // indirect
 	github.com/pjbgf/sha1cd v0.6.0 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
 	github.com/skeema/knownhosts v1.3.1 // indirect
 	github.com/xanzy/ssh-agent v0.3.3 // indirect
+	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	golang.org/x/crypto v0.50.0 // indirect
 	golang.org/x/net v0.53.0 // indirect
 	golang.org/x/sys v0.43.0 // indirect
go.sum +23 −0
@@ -19,6 +19,18 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
+github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
+github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
+github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
+github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
 github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
 github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
 github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
@@ -71,8 +83,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
 github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
 github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
 github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
@@ -85,6 +103,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
@@ -100,6 +121,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
 github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
 github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
internal/cli/verbs.go +256 −0
@@ -0,0 +1,256 @@
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
+
+	"custard/internal/release"
+	"github.com/charmbracelet/lipgloss"
+)
+
+var (
+	okStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true)
+	badStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true)
+	dimStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
+	urlStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Underline(true)
+	headStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Bold(true)
+)
+
+// fail prints a styled error and exits non-zero.
+func fail(format string, a ...any) {
+	fmt.Fprintln(os.Stderr, badStyle.Render("✗ ")+fmt.Sprintf(format, a...))
+	os.Exit(1)
+}
+
+// Check runs the repo's checks against the working tree (dirty allowed).
+func Check(args []string) {
+	cfg := mustLoad()
+	if len(cfg.CI) == 0 {
+		fail("no `ci:` commands in .custard.yaml")
+	}
+	fmt.Println(headStyle.Render("▍ check") + dimStyle.Render("  (working tree)"))
+	runChecks(cfg.CI, ".")
+	fmt.Println(okStyle.Render("✓ all checks passed"))
+}
+
+// Preview builds an export of HEAD, runs checks against it, and deploys a preview.
+func Preview(args []string) {
+	cfg := mustLoad()
+	requirePushed()
+	commit := gitOut("rev-parse", "HEAD")
+	fmt.Println(headStyle.Render("▍ preview") + dimStyle.Render("  "+shortCommit(commit)))
+
+	dir := exportHEAD()
+	defer os.RemoveAll(dir)
+	runChecks(cfg.CI, dir)
+
+	if cfg.Deploy.Preview == "" {
+		fail("no `deploy.preview` command in .custard.yaml")
+	}
+	out := step("deploying preview", func() (string, error) { return sh(dir, cfg.Deploy.Preview) })
+	url := lastURL(out)
+	if url == "" {
+		fail("deploy succeeded but no URL found in output")
+	}
+	saveState(repoName(), state{Commit: commit, PreviewURL: url})
+	fmt.Println(okStyle.Render("✓ preview ready"))
+	fmt.Println("  " + urlStyle.Render(url))
+}
+
+// Promote promotes the last preview (or a given URL) to production.
+func Promote(args []string) {
+	cfg := mustLoad()
+	url := ""
+	if len(args) > 0 {
+		url = args[0]
+	} else if st, ok := loadState(repoName()); ok {
+		url = st.PreviewURL
+	}
+	if url == "" {
+		fail("no preview to promote — run `custard preview` first (or pass a url)")
+	}
+	cmd := cfg.Deploy.Promote
+	if cmd == "" {
+		cmd = "vercel promote {{url}}"
+	}
+	cmd = strings.ReplaceAll(cmd, "{{url}}", url)
+	fmt.Println(headStyle.Render("▍ promote") + dimStyle.Render("  "+url))
+	step("promoting to production", func() (string, error) { return sh(".", cmd) })
+	fmt.Println(okStyle.Render("✓ promoted to production"))
+}
+
+// --- helpers ---
+
+func mustLoad() release.RepoConfig {
+	cfg, err := release.LoadLocal(".")
+	if err != nil {
+		fail("%v", err)
+	}
+	return cfg
+}
+
+func runChecks(cmds []string, dir string) {
+	for _, c := range cmds {
+		step("check: "+c, func() (string, error) { return sh(dir, c) })
+	}
+}
+
+// step shows a spinner while fn runs, then ✓; on error prints ✗ + output and exits.
+func step(label string, fn func() (string, error)) string {
+	tty := isTTY()
+	done := make(chan struct{})
+	if tty {
+		go spin(label, done)
+	}
+	out, err := fn()
+	close(done)
+	if tty {
+		fmt.Print("\r\033[K") // clear spinner line
+	}
+	if err != nil {
+		fmt.Println(badStyle.Render("✗ ") + label)
+		if s := strings.TrimSpace(out); s != "" {
+			fmt.Println(dimStyle.Render(indent(s)))
+		}
+		os.Exit(1)
+	}
+	fmt.Println(okStyle.Render("✓ ") + label)
+	return out
+}
+
+func spin(label string, done chan struct{}) {
+	frames := []rune("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
+	for i := 0; ; i++ {
+		select {
+		case <-done:
+			return
+		default:
+			fmt.Printf("\r%s %s", string(frames[i%len(frames)]), label)
+			time.Sleep(80 * time.Millisecond)
+		}
+	}
+}
+
+func isTTY() bool {
+	fi, err := os.Stdout.Stat()
+	return err == nil && fi.Mode()&os.ModeCharDevice != 0
+}
+
+func indent(s string) string {
+	return "  " + strings.ReplaceAll(s, "\n", "\n  ")
+}
+
+// sh runs a shell command line in dir and returns combined output.
+func sh(dir, cmdline string) (string, error) {
+	cmd := exec.Command("sh", "-c", cmdline)
+	cmd.Dir = dir
+	b, err := cmd.CombinedOutput()
+	return string(b), err
+}
+
+func gitOut(args ...string) string {
+	b, err := exec.Command("git", args...).Output()
+	if err != nil {
+		fail("git %s: %v", strings.Join(args, " "), err)
+	}
+	return strings.TrimSpace(string(b))
+}
+
+func shortCommit(c string) string {
+	if len(c) >= 8 {
+		return c[:8]
+	}
+	return c
+}
+
+// requirePushed ensures HEAD exists on a remote (so deployed == in-repo).
+func requirePushed() {
+	out, _ := exec.Command("git", "branch", "-r", "--contains", "HEAD").Output()
+	if !strings.Contains(string(out), "/") {
+		fail("HEAD isn't pushed — push to soft first, then preview")
+	}
+}
+
+// exportHEAD writes a clean checkout of HEAD to a temp dir (uncommitted edits excluded).
+func exportHEAD() string {
+	dir, err := os.MkdirTemp("", "custard-build-")
+	if err != nil {
+		fail("temp dir: %v", err)
+	}
+	cmd := exec.Command("sh", "-c", "git archive HEAD | tar -x -C "+dir)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		os.RemoveAll(dir)
+		fail("export HEAD: %v: %s", err, out)
+	}
+	return dir
+}
+
+var urlRe = regexp.MustCompile(`https?://[^\s]+`)
+
+// lastURL returns the last http(s) URL in the output (vercel prints it last).
+func lastURL(out string) string {
+	m := urlRe.FindAllString(out, -1)
+	if len(m) == 0 {
+		return ""
+	}
+	return strings.TrimRight(m[len(m)-1], ".,)")
+}
+
+func repoName() string {
+	b, err := exec.Command("git", "remote", "get-url", "origin").Output()
+	if err != nil {
+		if wd, e := os.Getwd(); e == nil {
+			return filepath.Base(wd)
+		}
+		return "repo"
+	}
+	s := strings.TrimSuffix(strings.TrimSpace(string(b)), ".git")
+	if i := strings.LastIndexAny(s, ":/"); i >= 0 {
+		s = s[i+1:]
+	}
+	return s
+}
+
+// --- local state (~/.custard/state.json): last preview per repo ---
+
+type state struct {
+	Commit     string `json:"commit"`
+	PreviewURL string `json:"preview_url"`
+}
+
+func statePath() string {
+	home, _ := os.UserHomeDir()
+	return filepath.Join(home, ".custard", "state.json")
+}
+
+func loadState(repo string) (state, bool) {
+	b, err := os.ReadFile(statePath())
+	if err != nil {
+		return state{}, false
+	}
+	var m map[string]state
+	if json.Unmarshal(b, &m) != nil {
+		return state{}, false
+	}
+	st, ok := m[repo]
+	return st, ok
+}
+
+func saveState(repo string, st state) {
+	p := statePath()
+	_ = os.MkdirAll(filepath.Dir(p), 0o755)
+	m := map[string]state{}
+	if b, err := os.ReadFile(p); err == nil {
+		_ = json.Unmarshal(b, &m)
+	}
+	m[repo] = st
+	if b, err := json.MarshalIndent(m, "", "  "); err == nil {
+		_ = os.WriteFile(p, b, 0o644)
+	}
+}
internal/release/release.go +19 −1
@@ -22,15 +22,33 @@ 	"custard/internal/license"
 	"gopkg.in/yaml.v3"
 )
 
-// RepoConfig is the opt-in `.custard.yaml` at a repo's root.
+// RepoConfig is the `.custard.yaml` at a repo's root.
 type RepoConfig struct {
 	Brew struct {
 		Enabled bool   `yaml:"enabled"`
 		Package string `yaml:"package"` // go build path, default "."
 	} `yaml:"brew"`
+	CI     []string `yaml:"ci"` // check commands, run in order; nonzero exit = fail
+	Deploy struct {
+		Preview string `yaml:"preview"` // command that deploys a preview, prints URL on stdout
+		Promote string `yaml:"promote"` // command to promote a preview; {{url}} is substituted
+	} `yaml:"deploy"`
 }
 
 var configNames = []string{".custard.yaml", ".custard.yml"}
+
+// LoadLocal reads `.custard.yaml` from a local directory (the CLI's cwd).
+func LoadLocal(dir string) (RepoConfig, error) {
+	var c RepoConfig
+	for _, fn := range configNames {
+		data, err := os.ReadFile(filepath.Join(dir, fn))
+		if err != nil {
+			continue
+		}
+		return c, yaml.Unmarshal(data, &c)
+	}
+	return c, fmt.Errorf("no .custard.yaml in %s", dir)
+}
 
 // ReadRepoConfig loads `.custard.yaml` at ref. ok=false when absent/invalid.
 func ReadRepoConfig(s *gitread.Store, repo, ref string) (RepoConfig, bool) {