package cli import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "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) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).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() commit := deployCommit() fmt.Println(headStyle.Render("▍ preview") + dimStyle.Render(" "+shortCommit(commit))) dir := exportCommit(commit) defer os.RemoveAll(dir) runChecks(cfg.CI, dir) if cfg.Deploy.Preview == "" { fail("no `deploy.preview` command in .custard.yaml") } warnVercelUnlinked(dir, cfg.Deploy.Preview) out := step("deploying preview", func() (string, error) { return sh(dir, cfg.Deploy.Preview) }) url := deployURL(out) if url == "" { fail("deploy succeeded but no URL found in output") } saveState(repoName(), state{Commit: commit, PreviewURL: url}) reportStatus(repoName(), commit, "preview", 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, commit := "", "" if st, ok := loadState(repoName()); ok { url, commit = st.PreviewURL, st.Commit } if len(args) > 0 { url = args[0] } 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)) out := step("promoting to production", func() (string, error) { return sh(".", cmd) }) prodURL := deployURL(out) if prodURL == "" { prodURL = url } reportStatus(repoName(), commit, "prod", prodURL) fmt.Println(okStyle.Render("✓ promoted to production")) } // custardConn returns the custard base URL + status token from env or ~/.custardrc. func custardConn() (baseURL, token string) { baseURL, token = os.Getenv("CUSTARD_URL"), os.Getenv("CUSTARD_TOKEN") if baseURL == "" || token == "" { home, _ := os.UserHomeDir() if b, err := os.ReadFile(filepath.Join(home, ".custardrc")); err == nil { var rc struct { URL string `json:"url"` Token string `json:"token"` } if json.Unmarshal(b, &rc) == nil { if baseURL == "" { baseURL = rc.URL } if token == "" { token = rc.Token } } } } return } // reportStatus posts a signed deploy-status update to custard. Best-effort: a // failure (or no config) never breaks the verb. func reportStatus(repo, commit, st, url string) { base, token := custardConn() if base == "" || token == "" { return } body, _ := json.Marshal(map[string]string{"repo": repo, "commit": commit, "state": st, "url": url}) mac := hmac.New(sha256.New, []byte(token)) mac.Write(body) req, err := http.NewRequest("POST", strings.TrimRight(base, "/")+"/status", bytes.NewReader(body)) if err != nil { return } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Custard-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil))) resp, err := http.DefaultClient.Do(req) if err != nil { fmt.Fprintln(os.Stderr, dimStyle.Render(" (status not reported: "+err.Error()+")")) return } resp.Body.Close() } // Release runs checks against HEAD, then tags and pushes — the Soft Serve // webhook auto-publishes the new version to the Homebrew tap. func Release(args []string) { if len(args) == 0 { fail("usage: custard release vX.Y.Z") } ver := args[0] if !release.IsReleaseTag(ver) { fail("version must be semver vX.Y.Z (got %q)", ver) } cfg := mustLoad() commit := deployCommit() fmt.Println(headStyle.Render("▍ release "+ver) + dimStyle.Render(" "+shortCommit(commit))) dir := exportCommit(commit) defer os.RemoveAll(dir) runChecks(cfg.CI, dir) step("tagging "+ver, func() (string, error) { return sh(".", "git tag -a "+ver+" -m "+ver+" "+commit) }) step("pushing tag", func() (string, error) { return sh(".", "git push origin "+ver) }) fmt.Println(okStyle.Render("✓ released " + ver)) fmt.Println(dimStyle.Render(" tag pushed — the tap updates automatically (brew upgrade shortly)")) } // --- 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 ") } // warnVercelUnlinked flags the common footgun: `vercel deploy` run against the // clean `git archive HEAD` export has no .vercel/project.json (it's gitignored, // so it never lands in the archive). Without it the Vercel CLI creates a STRAY // new project named after the temp dir instead of deploying to the linked one. // The fix is to pin VERCEL_ORG_ID / VERCEL_PROJECT_ID in the deploy command. func warnVercelUnlinked(dir, cmd string) { if !strings.Contains(cmd, "vercel") { return // not a Vercel deploy; nothing to check } if strings.Contains(cmd, "VERCEL_PROJECT_ID") || strings.Contains(cmd, "--project") { return // already pinned } if _, err := os.Stat(filepath.Join(dir, ".vercel", "project.json")); err == nil { return // export somehow carries the link; fine } fmt.Println(warnStyle.Render("⚠ vercel deploy has no project link") + dimStyle.Render(" — the clean HEAD export omits .vercel/project.json")) fmt.Println(dimStyle.Render(" Vercel will create a STRAY project. Pin it in .custard.yaml deploy.preview:")) fmt.Println(dimStyle.Render(" VERCEL_ORG_ID=… VERCEL_PROJECT_ID=… vercel deploy --yes")) } // 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 } // deployCommit resolves the commit to build and deploy: the newest non-empty // ancestor of HEAD that exists on a remote. Deploying the PUSHED commit keeps // "deployed == what's in the repo"; skipping empty commits sidesteps jj, whose // undescribed working-copy commit normally sits on top of HEAD after a push — // archiving that would ship an empty tree. func deployCommit() string { c := gitOut("rev-parse", "HEAD") for isEmptyCommit(c) { parent, err := exec.Command("git", "rev-parse", c+"^").Output() if err != nil { fail("no non-empty commit at or below HEAD to deploy — commit your work first") } c = strings.TrimSpace(string(parent)) } out, _ := exec.Command("git", "branch", "-r", "--contains", c).Output() if !strings.Contains(string(out), "/") { fail("commit %s isn't pushed — push to the remote first, then deploy", shortCommit(c)) } return c } // isEmptyCommit reports whether a commit has no diff against its parent (an // empty, usually undescribed, commit — e.g. jj's working-copy commit). A root // commit (no parent) is never treated as empty. func isEmptyCommit(c string) bool { if err := exec.Command("git", "rev-parse", "--verify", "-q", c+"^").Run(); err != nil { return false } // `git diff --quiet` exits 0 when the two trees are identical. return exec.Command("git", "diff", "--quiet", c+"^", c).Run() == nil } // exportCommit writes a clean checkout of a commit to a temp dir (uncommitted // edits and gitignored files are excluded — it's a pure `git archive`). func exportCommit(commit string) string { dir, err := os.MkdirTemp("", "custard-build-") if err != nil { fail("temp dir: %v", err) } cmd := exec.Command("sh", "-c", "git archive "+commit+" | tar -x -C "+dir) if out, err := cmd.CombinedOutput(); err != nil { os.RemoveAll(dir) fail("export %s: %v: %s", shortCommit(commit), err, out) } return dir } var urlRe = regexp.MustCompile(`https?://[^\s"',)]+`) // deployURL returns the public deployment URL from command output. It strips // surrounding punctuation and ignores Vercel meta hosts (api.vercel.com, the // dashboard/inspect URL, and the sso-api auth redirect — all on vercel.com) so // the real *.vercel.app (or custom-domain) URL is surfaced, not an API or // inspect link. Non-Vercel tools are unaffected: the last URL is returned. func deployURL(out string) string { m := urlRe.FindAllString(out, -1) var last string for _, u := range m { u = strings.Trim(u, `."',)`) if strings.Contains(u, "vercel.com") { continue } last = u } return last } 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) } }