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