carlos / platform Public

feat: carlos domains attach/detach/list (#151)

CLI front over internal/console/apidomains.go: apiSession.domainAttach/
domainDetach/domainList (apimode.go) POST/DELETE/GET the app's /domains
door, and runDomains (domains_cli.go) is the attach/detach/list verb
dispatch, following instances_cli.go's shape exactly (requireAPISession,
API-mode only). 409 on attach surfaces the server's "hostname unavailable"
verbatim via apiErrorFromBody; detach's `removed` and attach's `created`
drive the attached/already-attached and detached/was-not-attached prints;
list renders HOST ROUTE STATUS DNS CERT-EXPIRES with "-" for absent
dns_status/cert_expires_at (a claim no live box has reported on yet).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a60f3096924ffa0880536f92bfa68af6bee0274f parent cc37b37
4 files changed, +633 −0
  • apimode.go +86 −0
  • domains_cli.go +150 −0
  • domains_cli_test.go +393 −0
  • main.go +4 −0
diff --git a/apimode.go b/apimode.go
index a26cd81bb..2972802b1 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1381,6 +1381,92 @@ func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host st
return nil
}
+// apiDomainRecord mirrors the domains door's per-row wire shape
+// (domainListItem, internal/console/apidomains.go) — DNSStatus and
+// CertExpiresAt are the box-stamped reading columns and stay their zero
+// value ("" / 0) for a claim no live box has reported on yet ("pending"),
+// the same absent-stays-absent convention status.AliasReading uses.
+type apiDomainRecord struct {
+ Host string `json:"host"`
+ Route string `json:"route"`
+ Status string `json:"status"`
+ DNSStatus string `json:"dns_status,omitempty"`
+ CertExpiresAt int64 `json:"cert_expires_at,omitempty"`
+}
+
+// domainAttach asks the console to claim host for app, pointed at route (a
+// route hostname, or "" to let the console default it to the app's single
+// instance record). created is the caller's own answer — false on a re-
+// attach that changed nothing or only repointed the route — and
+// resolvedRoute is always the route the claim actually carries, whatever
+// this call sent: a caller that omitted -route learns what the server
+// defaulted it to. A 409 (another account or app already holds the
+// hostname) surfaces as "hostname unavailable" verbatim, apiErrorFromBody's
+// json-first decode doing the work — the door never says who holds it.
+func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, route string) (created bool, resolvedRoute string, err error) {
+ in := struct {
+ Host string `json:"host"`
+ Route string `json:"route,omitempty"`
+ }{host, route}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/domains"), in)
+ if err != nil {
+ return false, "", err
+ }
+ if status != http.StatusOK {
+ return false, "", apiErrorFromBody(status, body)
+ }
+ var out struct {
+ Host string `json:"host"`
+ Route string `json:"route"`
+ Created bool `json:"created"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return false, "", fmt.Errorf("domains attach: unreadable response: %w", err)
+ }
+ return out.Created, out.Route, nil
+}
+
+// domainDetach releases a hostname claim. Always 200 on a live console
+// (handleAPIDomainDetach's own contract): removed is false for a hostname
+// this app never held, one it no longer holds, or one another account
+// holds — the door's own quiet-in-both-directions neutrality (spec §5), not
+// distinguished here either.
+func (s *apiSession) domainDetach(ctx context.Context, a apiAcct, app, host string) (removed bool, err error) {
+ status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/domains/"+url.PathEscape(host)))
+ if err != nil {
+ return false, err
+ }
+ if status != http.StatusOK {
+ return false, apiErrorFromBody(status, body)
+ }
+ var out struct {
+ Removed bool `json:"removed"`
+ }
+ if err := json.Unmarshal(body, &out); err != nil {
+ return false, fmt.Errorf("domains detach: unreadable response: %w", err)
+ }
+ return out.Removed, nil
+}
+
+// domainList reads the app's hostname claims, each joined to the fleet's
+// readings sweep for status/dns_status/cert_expires_at.
+func (s *apiSession) domainList(ctx context.Context, a apiAcct, app string) ([]apiDomainRecord, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/domains"))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Domains []apiDomainRecord `json:"domains"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("domains list: unreadable response: %w", err)
+ }
+ return res.Domains, nil
+}
+
// shipTimeout bounds ship's own context — generous on purpose. The console
// gives itself up to 10 minutes to read the request body and 2 minutes to
// store it (internal/console/apicli.go), well past the 30s absolute Timeout
diff --git a/domains_cli.go b/domains_cli.go
new file mode 100644
index 000000000..4e5330923
--- /dev/null
+++ b/domains_cli.go
@@ -0,0 +1,150 @@
+// The `carlos domains` verb family (Task 10, issue #151): attach a
+// customer's own hostname to an app's route, detach it, or list what an app
+// currently holds — the CLI front over internal/console/apidomains.go, the
+// same instance-gated console door titogo's router speaks with its
+// CARLOS_INSTANCE_TOKEN. API mode only, the same as `carlos instances
+// create/list/delete`: a claim is a console-authorized write, and there is
+// no store-mode equivalent.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "strings"
+ "time"
+)
+
+const domainsUsage = `usage: carlos domains <attach|detach|list> [flags]
+
+ attach -app <a> [-route <r>] [flags] <hostname>
+ claim a customer's hostname for the app, pointed at
+ -route (default: the app's single instance record, if it
+ has exactly one — otherwise -route is required)
+ detach -app <a> [flags] <hostname>
+ release a hostname claim
+ list -app <a> [flags]
+ this app's hostname claims, joined to the fleet's own
+ readings sweep (DNS/CERT-EXPIRES)
+
+ -account <a> account (sqid or name; default "ops")
+ -account-id <id> account sqid, matched as a sqid ONLY
+ -console <origin> console to act through (default: the only logged-in one)
+
+Signed-in only (carlos auth login): a claim is a console-authorized write —
+the console checks the hostname against the deploy's deny list, resolves the
+route, and writes it. The hostname comes live on the box that owns the route
+at its next domains pass.
+`
+
+const domainsAttachUsage = `usage: carlos domains attach -app <a> [-route <r>] [-account <a>] [-account-id <id>] [-console <origin>] <hostname>
+`
+
+const domainsDetachUsage = `usage: carlos domains detach -app <a> [-account <a>] [-account-id <id>] [-console <origin>] <hostname>
+`
+
+const domainsListUsage = `usage: carlos domains list -app <a> [-account <a>] [-account-id <id>] [-console <origin>]
+`
+
+// runDomains dispatches the domains sub-verbs. attach/detach/list are
+// positional words, not flags, and must never reach fs.Parse — the same
+// shape runInstances uses for its own sub-verbs.
+func runDomains(args []string) {
+ if len(args) > 0 {
+ switch args[0] {
+ case "attach":
+ runDomainsAttach(args[1:])
+ return
+ case "detach":
+ runDomainsDetach(args[1:])
+ return
+ case "list":
+ runDomainsList(args[1:])
+ return
+ }
+ }
+ log.Fatal(domainsUsage)
+}
+
+func runDomainsAttach(args []string) {
+ fs := flag.NewFlagSet("domains attach", flag.ExitOnError)
+ app := fs.String("app", "", "app the hostname belongs to")
+ route := fs.String("route", "", "route hostname to attach to")
+ consoleFlag := fs.String("console", "", "console to attach through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ host := strings.TrimSpace(fs.Arg(0))
+ if *app == "" || host == "" || fs.NArg() > 1 {
+ log.Fatal(domainsAttachUsage)
+ }
+ sess := requireAPISession("attach", *consoleFlag)
+ printUsing(stdout, sess)
+ created, resolvedRoute, err := sess.domainAttach(context.Background(), apiAccount(acctRaw, acctIDRaw),
+ *app, host, strings.TrimSpace(*route))
+ if err != nil {
+ log.Fatalf("domains attach: %v", err)
+ }
+ if created {
+ fmt.Fprintf(stdout, "%s %s -> %s\n", colorFor(stdout).ok("attached"), host, resolvedRoute)
+ } else {
+ fmt.Fprintf(stdout, "%s %s -> %s\n", colorFor(stdout).ok("already attached"), host, resolvedRoute)
+ }
+}
+
+func runDomainsDetach(args []string) {
+ fs := flag.NewFlagSet("domains detach", flag.ExitOnError)
+ app := fs.String("app", "", "app the hostname belongs to")
+ consoleFlag := fs.String("console", "", "console to detach through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ host := strings.TrimSpace(fs.Arg(0))
+ if *app == "" || host == "" || fs.NArg() > 1 {
+ log.Fatal(domainsDetachUsage)
+ }
+ sess := requireAPISession("detach", *consoleFlag)
+ printUsing(stdout, sess)
+ removed, err := sess.domainDetach(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, host)
+ if err != nil {
+ log.Fatalf("domains detach: %v", err)
+ }
+ if removed {
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("detached"), host)
+ } else {
+ fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("was not attached"), host)
+ }
+}
+
+func runDomainsList(args []string) {
+ fs := flag.NewFlagSet("domains list", flag.ExitOnError)
+ app := fs.String("app", "", "app whose hostname claims to list")
+ consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ if *app == "" {
+ log.Fatal(domainsListUsage)
+ }
+ sess := requireAPISession("list", *consoleFlag)
+ printUsing(stdout, sess)
+ recs, err := sess.domainList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app)
+ if err != nil {
+ log.Fatalf("domains list: %v", err)
+ }
+ w := newTable(stdout)
+ fmt.Fprintln(w, "HOST\tROUTE\tSTATUS\tDNS\tCERT-EXPIRES")
+ for _, r := range recs {
+ dns := r.DNSStatus
+ if dns == "" {
+ dns = "-"
+ }
+ cert := "-"
+ if r.CertExpiresAt > 0 {
+ cert = time.Unix(r.CertExpiresAt, 0).UTC().Format("2006-01-02")
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", r.Host, r.Route, r.Status, dns, cert)
+ }
+ w.Flush()
+}
diff --git a/domains_cli_test.go b/domains_cli_test.go
new file mode 100644
index 000000000..485b06046
--- /dev/null
+++ b/domains_cli_test.go
@@ -0,0 +1,393 @@
+// Tests for the `carlos domains` verb family — the CLI door onto
+// internal/console/apidomains.go (Task 10, issue #151). Same two-layer shape
+// fleets_cli_test.go established: apiSession-level tests drive the three new
+// client methods (apimode.go) directly against an httptest fake console
+// (path, body, wire-shape round trips), and RunDomains-level tests drive the
+// full verb (flag parsing, printing) against the same kind of fake console,
+// credentials written straight to disk. Usage-error paths (log.Fatal, would
+// kill an in-process test binary) run as a real subprocess, carlosTestEnv +
+// scratch cmd.Dir per CLAUDE.md's rule.
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// ---- apiSession-level: path + body + wire-shape round trips ---------------
+
+// TestAPISessionDomainAttach: domainAttach POSTs {host,route} to the app's
+// domains door and decodes created + the route the server resolved.
+func TestAPISessionDomainAttach(t *testing.T) {
+ var gotPath, gotMethod string
+ var gotBody struct{ Host, Route string }
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod = r.URL.Path, r.Method
+ json.NewDecoder(r.Body).Decode(&gotBody)
+ json.NewEncoder(w).Encode(map[string]any{
+ "host": gotBody.Host, "route": gotBody.Route, "created": true,
+ })
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito",
+ "tickets.example.com", "slug.go.tito.io")
+ if err != nil {
+ t.Fatalf("domainAttach: %v", err)
+ }
+ if gotPath != "/api/cli/apps/bab/tito/domains" || gotMethod != http.MethodPost {
+ t.Fatalf("request = %s %s, want POST .../tito/domains", gotMethod, gotPath)
+ }
+ if gotBody.Host != "tickets.example.com" || gotBody.Route != "slug.go.tito.io" {
+ t.Fatalf("body = %+v", gotBody)
+ }
+ if !created || route != "slug.go.tito.io" {
+ t.Fatalf("domainAttach = created=%v route=%q", created, route)
+ }
+}
+
+// TestAPISessionDomainAttachNoOp: created=false round-trips (the re-attach,
+// nothing-changed case), and the server's resolved route still comes back
+// even when the caller sent none.
+func TestAPISessionDomainAttachNoOp(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{
+ "host": "tickets.example.com", "route": "default.go.tito.io", "created": false,
+ })
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ created, route, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito",
+ "tickets.example.com", "")
+ if err != nil {
+ t.Fatalf("domainAttach: %v", err)
+ }
+ if created || route != "default.go.tito.io" {
+ t.Fatalf("domainAttach = created=%v route=%q, want created=false route=default.go.tito.io", created, route)
+ }
+}
+
+// TestAPISessionDomainAttachConflict: a 409 surfaces the server's
+// "hostname unavailable" message verbatim, apiErrorFromBody's json-first
+// decode doing the work — no special-casing needed on this side.
+func TestAPISessionDomainAttachConflict(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ json.NewEncoder(w).Encode(map[string]any{"error": "hostname unavailable"})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ _, _, err := s.domainAttach(context.Background(), apiAcct{Seg: "bab"}, "tito", "taken.example.com", "")
+ if err == nil || err.Error() != "hostname unavailable" {
+ t.Fatalf("domainAttach against 409 = %v, want exactly \"hostname unavailable\"", err)
+ }
+}
+
+// TestAPISessionDomainDetach: domainDetach DELETEs with the host
+// path-escaped and decodes removed.
+func TestAPISessionDomainDetach(t *testing.T) {
+ var gotPath, gotMethod string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod = r.URL.EscapedPath(), r.Method
+ json.NewEncoder(w).Encode(map[string]any{"host": "tickets.example.com", "removed": true})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ removed, err := s.domainDetach(context.Background(), apiAcct{Seg: "bab"}, "tito", "tickets.example.com")
+ if err != nil {
+ t.Fatalf("domainDetach: %v", err)
+ }
+ if gotMethod != http.MethodDelete {
+ t.Fatalf("method = %s, want DELETE", gotMethod)
+ }
+ if gotPath != "/api/cli/apps/bab/tito/domains/tickets.example.com" {
+ t.Fatalf("path = %s, want the app's domains door with the host appended", gotPath)
+ }
+ if !removed {
+ t.Fatal("removed = false, want true")
+ }
+}
+
+// TestAPISessionDomainDetachWasNotAttached: removed=false round-trips
+// unchanged — the door's own three-ways-the-same neutrality (spec §5).
+func TestAPISessionDomainDetachWasNotAttached(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"host": "ghost.example.com", "removed": false})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ removed, err := s.domainDetach(context.Background(), apiAcct{Seg: "bab"}, "tito", "ghost.example.com")
+ if err != nil {
+ t.Fatalf("domainDetach: %v", err)
+ }
+ if removed {
+ t.Fatal("removed = true, want false")
+ }
+}
+
+// TestAPISessionDomainList: domainList GETs the domains door and decodes the
+// "domains" wrapper, tolerating rows that omit dns_status/cert_expires_at
+// (a claim no live box has materialized yet — apidomains.go's "pending").
+func TestAPISessionDomainList(t *testing.T) {
+ var gotPath, gotMethod string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod = r.URL.Path, r.Method
+ json.NewEncoder(w).Encode(map[string]any{
+ "domains": []map[string]any{
+ {"host": "tickets.example.com", "route": "slug.go.tito.io", "status": "live",
+ "dns_status": "pointing", "cert_expires_at": 1799999999},
+ {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending"},
+ },
+ })
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ recs, err := s.domainList(context.Background(), apiAcct{Seg: "bab"}, "tito")
+ if err != nil {
+ t.Fatalf("domainList: %v", err)
+ }
+ if gotPath != "/api/cli/apps/bab/tito/domains" || gotMethod != http.MethodGet {
+ t.Fatalf("request = %s %s, want GET .../tito/domains", gotMethod, gotPath)
+ }
+ if len(recs) != 2 {
+ t.Fatalf("domainList returned %d records, want 2", len(recs))
+ }
+ if recs[0].Host != "tickets.example.com" || recs[0].Route != "slug.go.tito.io" ||
+ recs[0].Status != "live" || recs[0].DNSStatus != "pointing" || recs[0].CertExpiresAt != 1799999999 {
+ t.Fatalf("recs[0] = %+v", recs[0])
+ }
+ if recs[1].Host != "new.example.com" || recs[1].Status != "pending" ||
+ recs[1].DNSStatus != "" || recs[1].CertExpiresAt != 0 {
+ t.Fatalf("recs[1] = %+v, want empty dns_status and zero cert_expires_at", recs[1])
+ }
+}
+
+// TestAPISessionDomainDoorsSurfaceServerErrors: a non-2xx from any of the
+// three domain doors surfaces an error rather than a swallowed one.
+func TestAPISessionDomainDoorsSurfaceServerErrors(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ w.Write([]byte("404 page not found\n"))
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ acct := apiAcct{Seg: "bab"}
+
+ if _, _, err := s.domainAttach(context.Background(), acct, "ghost", "x.example.com", ""); err == nil {
+ t.Fatal("domainAttach against a 404 must error")
+ }
+ if _, err := s.domainDetach(context.Background(), acct, "ghost", "x.example.com"); err == nil {
+ t.Fatal("domainDetach against a 404 must error")
+ }
+ if _, err := s.domainList(context.Background(), acct, "ghost"); err == nil {
+ t.Fatal("domainList against a 404 must error")
+ }
+}
+
+// ---- RunDomains-level: full verb, flag parsing + printing -----------------
+
+// fakeDomainsConsole is a minimal in-process console answering every
+// domains door with fixed, easily-asserted bodies.
+type fakeDomainsConsole struct {
+ ts *httptest.Server
+ lastAttachPath string
+ lastAttachBody struct{ Host, Route string }
+ attachCreated bool
+ lastDetachPath string
+ detachRemoved bool
+}
+
+func newFakeDomainsConsole(t *testing.T) *fakeDomainsConsole {
+ t.Helper()
+ fc := &fakeDomainsConsole{attachCreated: true, detachRemoved: true}
+ fc.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/domains"):
+ fc.lastAttachPath = r.URL.Path
+ json.NewDecoder(r.Body).Decode(&fc.lastAttachBody)
+ route := fc.lastAttachBody.Route
+ if route == "" {
+ route = "default.go.tito.io"
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "host": fc.lastAttachBody.Host, "route": route, "created": fc.attachCreated,
+ })
+ case r.Method == http.MethodDelete:
+ fc.lastDetachPath = r.URL.Path
+ json.NewEncoder(w).Encode(map[string]any{"host": "x", "removed": fc.detachRemoved})
+ case r.Method == http.MethodGet:
+ json.NewEncoder(w).Encode(map[string]any{
+ "domains": []map[string]any{
+ {"host": "tickets.example.com", "route": "slug.go.tito.io", "status": "live",
+ "dns_status": "pointing", "cert_expires_at": 1799999999},
+ {"host": "new.example.com", "route": "slug.go.tito.io", "status": "pending"},
+ },
+ })
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ t.Cleanup(fc.ts.Close)
+ return fc
+}
+
+// domainsCLIFixture wires up a fake console plus a credential pointed at it,
+// entirely in-process — the same shape fleetsCLIFixture uses.
+func domainsCLIFixture(t *testing.T) *fakeDomainsConsole {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+ t.Setenv("CARLOS_CONSOLE", "")
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", "")
+ t.Chdir(t.TempDir())
+ creds := filepath.Join(home, "carlos-credentials")
+ t.Setenv("CARLOS_CREDENTIALS", creds)
+ fc := newFakeDomainsConsole(t)
+ writeTestCredential(t, creds, fc.ts.URL)
+ return fc
+}
+
+func TestRunDomainsAttachPrintsAttached(t *testing.T) {
+ fc := domainsCLIFixture(t)
+ fc.attachCreated = true
+ buf := captureStdout(t)
+
+ runDomains([]string{"attach", "-app", "tito", "-route", "slug.go.tito.io", "tickets.example.com"})
+
+ if fc.lastAttachBody.Host != "tickets.example.com" || fc.lastAttachBody.Route != "slug.go.tito.io" {
+ t.Fatalf("console saw body %+v", fc.lastAttachBody)
+ }
+ if !strings.Contains(buf.String(), "attached tickets.example.com -> slug.go.tito.io") {
+ t.Fatalf("stdout = %q, want the attached confirmation", buf.String())
+ }
+}
+
+func TestRunDomainsAttachPrintsAlreadyAttachedOnNoOp(t *testing.T) {
+ fc := domainsCLIFixture(t)
+ fc.attachCreated = false
+ buf := captureStdout(t)
+
+ runDomains([]string{"attach", "-app", "tito", "tickets.example.com"})
+
+ if !strings.Contains(buf.String(), "already attached") {
+ t.Fatalf("stdout = %q, want the already-attached confirmation", buf.String())
+ }
+}
+
+func TestRunDomainsDetachPrintsDetached(t *testing.T) {
+ fc := domainsCLIFixture(t)
+ fc.detachRemoved = true
+ buf := captureStdout(t)
+
+ runDomains([]string{"detach", "-app", "tito", "tickets.example.com"})
+
+ if !strings.HasSuffix(fc.lastDetachPath, "/domains/tickets.example.com") {
+ t.Fatalf("detach path = %s, want it to end in the escaped host", fc.lastDetachPath)
+ }
+ if !strings.Contains(buf.String(), "detached tickets.example.com") {
+ t.Fatalf("stdout = %q, want the detached confirmation", buf.String())
+ }
+}
+
+func TestRunDomainsDetachPrintsWasNotAttached(t *testing.T) {
+ fc := domainsCLIFixture(t)
+ fc.detachRemoved = false
+ buf := captureStdout(t)
+
+ runDomains([]string{"detach", "-app", "tito", "ghost.example.com"})
+
+ if !strings.Contains(buf.String(), "was not attached") {
+ t.Fatalf("stdout = %q, want the was-not-attached confirmation", buf.String())
+ }
+}
+
+// TestRunDomainsListPrintsTable: the list table carries every column and
+// falls back to "-" for the row with no dns_status/cert_expires_at.
+func TestRunDomainsListPrintsTable(t *testing.T) {
+ domainsCLIFixture(t)
+ buf := captureStdout(t)
+
+ runDomains([]string{"list", "-app", "tito"})
+
+ out := buf.String()
+ for _, want := range []string{
+ "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES",
+ "tickets.example.com", "slug.go.tito.io", "live", "pointing", "2027-01-15",
+ "new.example.com", "pending",
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("stdout missing %q:\n%s", want, out)
+ }
+ }
+ // The pending row's dns/cert columns must read "-", not a stray zero or
+ // blank cell that would misalign the table.
+ lines := strings.Split(out, "\n")
+ var pendingLine string
+ for _, l := range lines {
+ if strings.Contains(l, "new.example.com") {
+ pendingLine = l
+ }
+ }
+ if pendingLine == "" || !strings.Contains(pendingLine, "-") {
+ t.Fatalf("pending row = %q, want a \"-\" placeholder for the absent columns", pendingLine)
+ }
+}
+
+// ---- subprocess: the usage-error / bad-argument paths ---------------------
+
+// TestDomainsCLIUsageErrors drives every domains verb with missing or
+// malshaped arguments as a real subprocess (buildCarlos) — the only way to
+// observe a log.Fatal/os.Exit refusal without killing the test binary.
+// carlosTestEnv masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is
+// a scratch dir (CLAUDE.md's rule), so a case that DID try to dial out would
+// fail on "not logged in", not silently succeed.
+func TestDomainsCLIUsageErrors(t *testing.T) {
+ bin := buildCarlos(t)
+ scratch := t.TempDir()
+ env := carlosTestEnv(t)
+
+ run := func(args ...string) (string, error) {
+ cmd := exec.Command(bin, args...)
+ cmd.Env = env
+ cmd.Dir = scratch
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ for _, tc := range []struct {
+ name string
+ args []string
+ want string
+ }{
+ {"domains no args", []string{"domains"}, "usage: carlos domains"},
+ {"domains bogus verb", []string{"domains", "bogus"}, "usage: carlos domains"},
+ {"attach no app", []string{"domains", "attach", "tickets.example.com"}, "usage: carlos domains attach"},
+ {"attach no positional host", []string{"domains", "attach", "-app", "tito"}, "usage: carlos domains attach"},
+ {"detach no app", []string{"domains", "detach", "tickets.example.com"}, "usage: carlos domains detach"},
+ {"detach no positional host", []string{"domains", "detach", "-app", "tito"}, "usage: carlos domains detach"},
+ {"list no app", []string{"domains", "list"}, "usage: carlos domains list"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ out, err := run(tc.args...)
+ if err == nil {
+ t.Fatalf("carlos %s exited 0, want a nonzero (usage) exit:\n%s", strings.Join(tc.args, " "), out)
+ }
+ if !strings.Contains(out, tc.want) {
+ t.Fatalf("carlos %s output missing %q:\n%s", strings.Join(tc.args, " "), tc.want, out)
+ }
+ })
+ }
+}
diff --git a/main.go b/main.go
index b7a85b745..4b015b5c7 100644
--- a/main.go
+++ b/main.go
@@ -53,6 +53,8 @@ func main() {
runRoute(args)
case "instances":
runInstances(args)
+ case "domains":
+ runDomains(args)
case "ship":
runShip(args)
case "promote":
@@ -147,6 +149,8 @@ Your apps — ship and run software on a CARLOS deployment
env plain per-app config vars: set | unset | list
secrets sealed per-app secrets: set | unset | list | genkey
instances an app's instances: enable | create | list | delete
+ domains a customer's own hostname attached to an app's route:
+ attach | detach | list
logs an app's merged log timeline: its own output plus platform
events (wakes, restarts, failures); -f follows live
ledger open ledgers: append | head | list | publish | blob | verify