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) {