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

refactor: subcommand dispatch; server moves under 'custard serve'

094699372503b4714db97f06bd619793315bc79f
humdrum <me@humdrum.me> · 2026-06-18 07:54

parent fe6753f9

refactor: subcommand dispatch; server moves under 'custard serve'

Prep for the deploy/release CLI (TASK-007). One binary, subcommands:
- custard serve → the forge server (config.Load now parses a serve FlagSet)
- check/preview/promote/release → stubbed (next)
- internal/cli holds serve; deploy.sh ExecStart updated to 'custard serve'
Zero behavior change to the running forge.

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

6 files changed

- → Webhook-driven-auto-publish-to-the-Homebrew-tap.md +9 −9
@@ -1,10 +1,10 @@
 ---
 id: TASK-006
 title: Webhook-driven auto-publish to the Homebrew tap
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-18 05:53'
-updated_date: '2026-06-18 05:59'
+updated_date: '2026-06-18 06:33'
 labels:
   - feature
 dependencies: []
@@ -20,11 +20,11 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 Soft Serve branch_tag_create webhook (signed w/ secret) → custard endpoint POST /hooks/release
-- [ ] #2 Endpoint verifies the HMAC signature against a configured secret; rejects bad/unsigned
-- [ ] #3 Only semver tags (vX.Y.Z) trigger a release; non-semver/branch events ignored
-- [ ] #4 Per-repo opt-in: .custard.yaml at repo root (brew.enabled: true, optional package path); no file or disabled = skipped
-- [ ] #5 Release runs in custard (Go): archive tag → tarball to /dl → sha256 → render formula → commit to homebrew-tap bare repo
-- [ ] #6 Re-publishing same version overwrites cleanly; logged
-- [ ] #7 Docs: how to enable a repo + how to register the webhook (ssh soft repo webhook create)
+- [x] #1 Soft Serve branch_tag_create webhook (signed w/ secret) → custard endpoint POST /hooks/release
+- [x] #2 Endpoint verifies the HMAC signature against a configured secret; rejects bad/unsigned
+- [x] #3 Only semver tags (vX.Y.Z) trigger a release; non-semver/branch events ignored
+- [x] #4 Per-repo opt-in: .custard.yaml at repo root (brew.enabled: true, optional package path); no file or disabled = skipped
+- [x] #5 Release runs in custard (Go): archive tag → tarball to /dl → sha256 → render formula → commit to homebrew-tap bare repo
+- [x] #6 Re-publishing same version overwrites cleanly; logged
+- [x] #7 Docs: how to enable a repo + how to register the webhook (ssh soft repo webhook create)
 <!-- AC:END -->
- → custard-CI-deploy-gate-checks-before-deploy.md +67 −0
@@ -0,0 +1,67 @@
+---
+id: TASK-007
+title: 'custard CLI: preview / promote / release (Charm spinner)'
+status: "\U0001F7E2 In progress"
+assignee: []
+created_date: '2026-06-18 06:36'
+updated_date: '2026-06-18 14:51'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 7000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+A Charm-style CLI that owns DEPLOY + RELEASE — not git push (you keep committing/pushing via lazygit/Claude; deploy is decoupled since 'vercel deploy' uploads the local dir, not the remote). Foreground + inline spinner per stage, so no notification system needed. Reads .custard.yaml for check + deploy config. Verbs: (1) preview — run checks, then vercel deploy (preview), print URL; (2) promote — vercel promote the latest/chosen preview to prod (same build, no rebuild); (3) release vX.Y.Z — run checks, tag + push the tag so the existing brew webhook auto-publishes. Supersedes the earlier server-side-CI sketch in this task's ACs.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 CLI reads .custard.yaml: ci check commands + deploy (preview/prod) config
+- [ ] #2 promote: vercel promote latest preview (or 'promote <url>') → prod, no rebuild
+- [ ] #3 release vX.Y.Z: run checks, then tag + push tag → existing brew webhook publishes
+- [ ] #4 Charm UI (Bubble Tea/Lipgloss spinner); distributed via the self-hosted tap (dogfood)
+- [ ] #5 Does NOT perform git push — lazygit/Claude workflow unaffected
+- [ ] #6 Multiple previews before promote supported; promote defaults to most recent
+- [ ] #7 check verb: runs .custard.yaml checks on the working tree (dirty allowed) — fast inner loop, no deploy
+- [ ] #8 Status reporting: CLI POSTs signed {repo, commit, state(checked|preview|prod), url} to custard
+- [ ] #9 Forge badges: mark commits ✓ in production / 👁 preview / ✓ checked / ⚠ unverified so the live forge shows what's real vs in-flight
+- [ ] #10 preview: build a clean export of HEAD (git archive/worktree) → run checks → vercel deploy (preview) → print URL; spinner per stage, ✗ + captured output on failure
+- [ ] #11 Deploys build from the COMMIT, never the working dir — uncommitted edits are structurally excluded (never deployed), and a dirty tree does not block deploying committed work
+- [ ] #12 preview/promote require HEAD pushed to soft (deployed code == an in-repo commit the forge badge maps to); if unpushed, CLI says push first
+- [ ] #13 preview/release run checks against the EXPORTED HEAD (committed code); standalone check runs against the working tree — distinct by design
+- [ ] #14 Single 'custard' binary: 'serve' runs the server (droplet), client verbs (check/preview/promote/release) run locally; refactor cmd/custard into subcommands
+- [ ] #15 CLI config via ~/.custardrc (custard base URL + status token); vercel auth uses local vercel login
+- [ ] #16 Server: POST /status (HMAC-signed) persists {repo,commit,state,url} to a writable store (e.g. /var/lib/custard/state); systemd ReadWritePaths includes it
+- [ ] #17 promote tracks the last preview (local state, e.g. ~/.custard/state.json) or accepts an explicit url; errors clearly if no preview exists
+<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Design detail / schema:
+
+.custard.yaml (per repo):
+  brew: { enabled: true, package: "." }     # TASK-006 (release)
+  ci:                                          # commands run in order; nonzero exit = fail
+    - go vet ./...
+    - go test ./...
+  deploy:
+    preview: vercel deploy                      # must print the preview URL on stdout
+    # promote uses: vercel promote <url> (no rebuild)
+
+Verbs (one binary, 'custard <verb>'):
+  check            run ci on WORKING TREE (dirty ok); no deploy; fast inner loop
+  preview          export HEAD (git archive→tmp) → run ci on export → deploy.preview → capture+print URL → POST /status(preview)
+  promote [url]    vercel promote last/given preview → prod → POST /status(prod)
+  release vX.Y.Z   run ci on HEAD → tag + push tag (TASK-006 webhook publishes brew) → POST /status
+
+Gates: preview/promote do NOT block on a dirty tree (they build from the commit, so uncommitted edits are excluded); they DO require HEAD pushed to soft so deployed==in-repo and the badge maps. check has no gate.
+
+Server additions: POST /status (HMAC, STATUS_SECRET in /etc/custard.env) → persist per (repo,commit); forge reads it to badge commits/branch as: ✓ in production / 👁 preview / ✓ checked / ⚠ unverified. State store writable (ReadWritePaths). 
+
+Distribution: CLI ships via the self-hosted tap (custard formula) — dogfoods TASK-006. Open: status-store backend (flat json vs sqlite); whether release should also require clean tree.
+<!-- SECTION:NOTES:END -->
cmd/custard/main.go +36 −35
@@ -1,49 +1,50 @@
-// Command custard is a read-only web code forge over bare git repositories.
+// Command custard is a read-only web code forge over bare git repositories,
+// plus a small client for deploying and releasing from those repos.
+//
+//	custard serve     run the forge server (the droplet runs this)
+//	custard check     run a repo's checks against the working tree
+//	custard preview   build HEAD, run checks, deploy a preview
+//	custard promote   promote the last preview to production
+//	custard release   tag + push a release (auto-publishes to the Homebrew tap)
 package main
 
 import (
-	"context"
-	"errors"
-	"log"
-	"net/http"
+	"fmt"
 	"os"
-	"os/signal"
-	"syscall"
-	"time"
 
-	"custard/internal/config"
-	"custard/internal/server"
+	"custard/internal/cli"
 )
 
 func main() {
-	cfg := config.Load()
-	srv, err := server.New(cfg)
-	if err != nil {
-		log.Fatalf("init: %v", err)
+	if len(os.Args) < 2 {
+		usage()
+		os.Exit(2)
 	}
-
-	httpSrv := &http.Server{
-		Addr:              cfg.ListenAddr,
-		Handler:           srv.Handler(),
-		ReadHeaderTimeout: 10 * time.Second,
+	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])
+		os.Exit(1)
+	case "-h", "--help", "help":
+		usage()
+	default:
+		fmt.Fprintf(os.Stderr, "custard: unknown command %q\n\n", os.Args[1])
+		usage()
+		os.Exit(2)
 	}
+}
 
-	// Serve until an interrupt, then drain in flight requests.
-	go func() {
-		log.Printf("custard listening on %s (repos: %s)", cfg.ListenAddr, cfg.ReposPath)
-		if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
-			log.Fatalf("server error: %v", err)
-		}
-	}()
+func usage() {
+	fmt.Fprint(os.Stderr, `custard — web code forge + deploy/release client
 
-	stop := make(chan os.Signal, 1)
-	signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
-	<-stop
+usage:
+  custard serve [flags]    run the forge server
+  custard check            run checks on the working tree
+  custard preview          build HEAD, check, deploy a preview
+  custard promote [url]    promote the last preview to production
+  custard release vX.Y.Z   tag + push a release to the Homebrew tap
 
-	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
-	defer cancel()
-	if err := httpSrv.Shutdown(ctx); err != nil {
-		log.Printf("shutdown error: %v", err)
-	}
-	log.Println("custard stopped")
+run "custard serve -h" for server flags
+`)
 }
deploy/deploy.sh +1 −1
@@ -60,7 +60,7 @@ [Service]
 User=${RUN_USER}
 Group=${RUN_USER}
 EnvironmentFile=-/etc/custard.env
-ExecStart=/usr/local/bin/custard --repos ${REPOS_PATH} --addr 127.0.0.1:8080 --base-url https://${DOMAIN} --soft-serve-http https://${DOMAIN}/git --tap-repo ${TAP_REPO} --dl-path ${DL_PATH}${dbflag}
+ExecStart=/usr/local/bin/custard serve --repos ${REPOS_PATH} --addr 127.0.0.1:8080 --base-url https://${DOMAIN} --soft-serve-http https://${DOMAIN}/git --tap-repo ${TAP_REPO} --dl-path ${DL_PATH}${dbflag}
 Restart=on-failure
 RestartSec=2
 NoNewPrivileges=true
internal/cli/serve.go +51 −0
@@ -0,0 +1,51 @@
+// Package cli implements custard's subcommands: the long-running forge server
+// (serve) and the local client verbs (check/preview/promote/release).
+package cli
+
+import (
+	"context"
+	"errors"
+	"log"
+	"net/http"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"custard/internal/config"
+	"custard/internal/server"
+)
+
+// Serve runs the forge HTTP server until interrupted, then drains in-flight
+// requests. This is what the droplet runs (via systemd).
+func Serve(args []string) {
+	cfg := config.Load(args)
+	srv, err := server.New(cfg)
+	if err != nil {
+		log.Fatalf("init: %v", err)
+	}
+
+	httpSrv := &http.Server{
+		Addr:              cfg.ListenAddr,
+		Handler:           srv.Handler(),
+		ReadHeaderTimeout: 10 * time.Second,
+	}
+
+	go func() {
+		log.Printf("custard listening on %s (repos: %s)", cfg.ListenAddr, cfg.ReposPath)
+		if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+			log.Fatalf("server error: %v", err)
+		}
+	}()
+
+	stop := make(chan os.Signal, 1)
+	signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
+	<-stop
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	if err := httpSrv.Shutdown(ctx); err != nil {
+		log.Printf("shutdown error: %v", err)
+	}
+	log.Println("custard stopped")
+}
internal/config/config.go +13 −11
@@ -18,18 +18,20 @@ 	DLPath        string // directory where release tarballs are written (served at /dl)
 	TapRepo       string // bare repo name of the Homebrew tap (default "homebrew-tap")
 }
 
-// Load parses flags (with env fallbacks) and returns the config.
-func Load() Config {
+// Load parses the `serve` flags (with env fallbacks) from args and returns the
+// config. Pass the subcommand's arguments (os.Args[2:]).
+func Load(args []string) Config {
+	fs := flag.NewFlagSet("serve", flag.ExitOnError)
 	var c Config
-	flag.StringVar(&c.ReposPath, "repos", env("REPOS_PATH", "./repos"), "directory of bare *.git repositories")
-	flag.StringVar(&c.ListenAddr, "addr", env("LISTEN_ADDR", ":8080"), "listen address")
-	flag.StringVar(&c.BaseURL, "base-url", env("BASE_URL", "http://localhost:8080"), "public base URL")
-	flag.StringVar(&c.SoftServeHTTP, "soft-serve-http", env("SOFT_SERVE_HTTP", "http://localhost:23232"), "git clone base shown in the UI footer (e.g. https://your-host/git)")
-	flag.StringVar(&c.SoftServeDB, "soft-serve-db", env("SOFT_SERVE_DB", ""), "path to soft-serve.db; when set, only public (non-private, non-hidden) repos are served")
-	flag.StringVar(&c.WebhookSecret, "webhook-secret", env("WEBHOOK_SECRET", ""), "shared secret for the Soft Serve release webhook (empty disables /hooks/release)")
-	flag.StringVar(&c.DLPath, "dl-path", env("DL_PATH", "/var/lib/custard/dl"), "directory where release tarballs are written")
-	flag.StringVar(&c.TapRepo, "tap-repo", env("TAP_REPO", "homebrew-tap"), "bare repo name of the Homebrew tap")
-	flag.Parse()
+	fs.StringVar(&c.ReposPath, "repos", env("REPOS_PATH", "./repos"), "directory of bare *.git repositories")
+	fs.StringVar(&c.ListenAddr, "addr", env("LISTEN_ADDR", ":8080"), "listen address")
+	fs.StringVar(&c.BaseURL, "base-url", env("BASE_URL", "http://localhost:8080"), "public base URL")
+	fs.StringVar(&c.SoftServeHTTP, "soft-serve-http", env("SOFT_SERVE_HTTP", "http://localhost:23232"), "git clone base shown in the UI footer (e.g. https://your-host/git)")
+	fs.StringVar(&c.SoftServeDB, "soft-serve-db", env("SOFT_SERVE_DB", ""), "path to soft-serve.db; when set, only public (non-private, non-hidden) repos are served")
+	fs.StringVar(&c.WebhookSecret, "webhook-secret", env("WEBHOOK_SECRET", ""), "shared secret for the Soft Serve release webhook (empty disables /hooks/release)")
+	fs.StringVar(&c.DLPath, "dl-path", env("DL_PATH", "/var/lib/custard/dl"), "directory where release tarballs are written")
+	fs.StringVar(&c.TapRepo, "tap-repo", env("TAP_REPO", "homebrew-tap"), "bare repo name of the Homebrew tap")
+	_ = fs.Parse(args)
 	return c
 }