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() 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") } warnVercelUnlinked(dir, cfg.Deploy.Preview) 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}) 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 := lastURL(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() requirePushed() fmt.Println(headStyle.Render("▍ release " + ver)) dir := exportHEAD() defer os.RemoveAll(dir) runChecks(cfg.CI, dir) step("tagging "+ver, func() (string, error) { return sh(".", "git tag -a "+ver+" -m "+ver) }) 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 } // 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) } }