carlos / platform Public

cli: carlos fleets verbs + apps -place

Adds the carlos fleets verbs on the apiSession pattern (mirrors apps.go's
runAppsCreate: resolveMode + session verbs, console-API-only with no
bucket-direct fallback):

  carlos fleets create -bucket <bucket> [-account <sqid|name>] <name>
  carlos fleets add-box [-account <sqid|name>] <fleet> <label>
  carlos fleets rotate-token [-account <sqid|name>] <fleet> <label>
  carlos fleets detach-box [-account <sqid|name>] <fleet> <label>
  carlos fleets ls [-account <sqid|name>]

add-box and rotate-token both print their token exactly once, through
one shared helper (printFleetToken) that carries Task 3's review
finding in both verbs' output and help text: rotation refuses a box's
NEXT dial-in but does NOT evict a channel it already holds — a
compromised box needs detach-box, not rotate-token.

apps.go: runAppsCreate gains -place fleet/<f> (client-side grammar
checked before any API call, sent in the create body per the
console-side ruling), and a new `carlos apps place -app <name> -target
fleet/<f>|""` verb. main.go's dispatch gains `case "fleets"`.

Tests (fleets_cli_test.go): apiSession-level path/body/wire-shape round
trips for all six new client calls against httptest fakes; RunFleets/
RunApps-level tests with good args (in-process, credentials written
directly, no login round trip) proving the token appears in stdout
exactly once with the detach warning, add-box vs rotate-token return
distinct tokens, fleets ls lists every box, and create-with-place /
apps place reach the console with the right body; one subprocess test
(buildCarlos, carlosTestEnv-masked env, cmd.Dir a scratch dir) covering
every verb's usage-error and bad-argument exit paths, which call
log.Fatal/os.Exit and so cannot be driven in-process.

Full gate green (18/18 packages), gofmt clean.
Paul Campbell 1e3b763f3d7149000abaaade16a624a504a19a16 parent cfe8fb2
6 files changed, +1254 −24
  • apimode.go +167 −3
  • apimode_test.go +46 −4
  • apps.go +105 −6
  • fleets.go +308 −0
  • fleets_cli_test.go +606 −0
  • main.go +22 −11
diff --git a/apimode.go b/apimode.go
index 09378fb82..93b75c420 100644
--- a/apimode.go
+++ b/apimode.go
@@ -356,10 +356,19 @@ type apiCreateAppResult struct {
// same claim the browser's New app form makes. The path is the literal
// `new` segment (mirroring /a/{sqid}/apps/new), which apiPath builds when
// handed "new" as the app.
-func (s *apiSession) createApp(ctx context.Context, a apiAcct, app string) (apiCreateAppResult, error) {
+//
+// place is "" or a "fleet/<name>" target (runAppsCreate's -place flag,
+// already grammar-checked client-side before this is ever called) — the
+// server (handleAPICreateApp, internal/console/apicli.go) applies the SAME
+// two checks `carlos apps place` uses on its own target (grammar again,
+// server-side, plus fleet ownership) before the app is created, so an
+// invalid or foreign place refuses the whole create rather than silently
+// creating an unplaced app.
+func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string) (apiCreateAppResult, error) {
in := struct {
- Name string `json:"name"`
- }{app}
+ Name string `json:"name"`
+ Place string `json:"place,omitempty"`
+ }{app, place}
status, body, err := s.postJSON(ctx, apiPath(a, "new", ""), in)
if err != nil {
return apiCreateAppResult{}, err
@@ -937,3 +946,158 @@ func writeFilePart(mw *multipart.Writer, name, path string) error {
_, err = io.Copy(part, f)
return err
}
+
+// ---- customer fleets (fleets.go's `carlos fleets` verbs + apps place) -----
+
+// fleetPath builds one /api/cli/fleets/{account}... path — the fleets
+// family's account-scoped door (internal/console/fleets.go), sibling to
+// apiPath's /api/cli/apps/{account}/{app} shape but with no app segment: a
+// fleet's identity is account + name, never account + app + anything. tail
+// is a bare path (any fleet/label segments the caller needs already
+// url.PathEscape'd into it) with no query string of its own — exactly
+// apiPath's own contract, for the same reason: -account-id's exact=sqid
+// switch is appended by plain concatenation below.
+func fleetPath(a apiAcct, tail string) string {
+ p := "/api/cli/fleets/" + url.PathEscape(a.Seg) + tail
+ if a.Exact {
+ p += "?exact=sqid"
+ }
+ return p
+}
+
+// apiFleetBox and apiFleetView are the wire shapes handleAPIFleetList and
+// handleAPIFleetNew send (internal/console/fleets.go's fleetBoxView /
+// fleetView) — deliberately distinct types from anything in internal/registry:
+// this client never sees a token hash, only ever a label and a status.
+type apiFleetBox struct {
+ Label string `json:"label"`
+ Status string `json:"status"`
+}
+
+type apiFleetView struct {
+ Name string `json:"name"`
+ Bucket string `json:"bucket"`
+ Boxes []apiFleetBox `json:"boxes"`
+}
+
+// createFleet mints one account-owned fleet record — `carlos fleets create`.
+func (s *apiSession) createFleet(ctx context.Context, a apiAcct, name, bucket string) (apiFleetView, error) {
+ in := struct {
+ Name string `json:"name"`
+ Bucket string `json:"bucket"`
+ }{name, bucket}
+ status, body, err := s.postJSON(ctx, fleetPath(a, "/new"), in)
+ if err != nil {
+ return apiFleetView{}, err
+ }
+ if status != http.StatusOK {
+ return apiFleetView{}, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Fleet apiFleetView `json:"fleet"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return apiFleetView{}, fmt.Errorf("fleets create: unreadable response: %w", err)
+ }
+ return res.Fleet, nil
+}
+
+// listFleets asks the console for every fleet the caller's account owns —
+// `carlos fleets ls`.
+func (s *apiSession) listFleets(ctx context.Context, a apiAcct) ([]apiFleetView, error) {
+ status, body, err := s.getJSON(ctx, fleetPath(a, ""))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Fleets []apiFleetView `json:"fleets"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("fleets ls: unreadable response: %w", err)
+ }
+ return res.Fleets, nil
+}
+
+// addFleetBox mints a box and its bearer token — `carlos fleets add-box`.
+// The plaintext is in the return value exactly once; RunFleets prints it
+// exactly once and it must never be logged or retried into a second print.
+func (s *apiSession) addFleetBox(ctx context.Context, a apiAcct, fleet, label string) (token string, err error) {
+ in := struct {
+ Label string `json:"label"`
+ }{label}
+ status, body, err := s.postJSON(ctx, fleetPath(a, "/"+url.PathEscape(fleet)+"/boxes"), in)
+ if err != nil {
+ return "", err
+ }
+ if status != http.StatusOK {
+ return "", apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Token string `json:"token"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return "", fmt.Errorf("fleets add-box: unreadable response: %w", err)
+ }
+ return res.Token, nil
+}
+
+// rotateFleetBoxToken mints a fresh token for a box that already exists —
+// `carlos fleets rotate-token`. Per handleAPIFleetRotateBox's doc comment
+// (internal/console/fleets.go): this refuses the box's NEXT dial-in attempt
+// with the OLD token, but does NOT evict a channel that is already
+// connected — a box whose credential may be compromised needs detach-box,
+// not this. RunFleets repeats that warning in this verb's own output.
+func (s *apiSession) rotateFleetBoxToken(ctx context.Context, a apiAcct, fleet, label string) (token string, err error) {
+ status, body, err := s.postJSON(ctx,
+ fleetPath(a, "/"+url.PathEscape(fleet)+"/boxes/"+url.PathEscape(label)+"/rotate"), struct{}{})
+ if err != nil {
+ return "", err
+ }
+ if status != http.StatusOK {
+ return "", apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Token string `json:"token"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return "", fmt.Errorf("fleets rotate-token: unreadable response: %w", err)
+ }
+ return res.Token, nil
+}
+
+// detachFleetBox flips a box to "detached" — the hard revocation rotation is
+// not: the edge closes an already-connected channel too
+// (handleAPIFleetDetachBox). `carlos fleets detach-box`.
+func (s *apiSession) detachFleetBox(ctx context.Context, a apiAcct, fleet, label string) error {
+ status, body, err := s.postJSON(ctx,
+ fleetPath(a, "/"+url.PathEscape(fleet)+"/boxes/"+url.PathEscape(label)+"/detach"), struct{}{})
+ if err != nil {
+ return err
+ }
+ if status != http.StatusOK {
+ return apiErrorFromBody(status, body)
+ }
+ return nil
+}
+
+// place asks the console to repoint where app's routes — and its
+// already-provisioned instances — should run: `carlos apps place`. target ""
+// clears placement back to platform-served/unconstrained; any other value
+// must be "fleet/<name>" naming a fleet the caller's own account owns
+// (handleAPIPlace, internal/console/fleets.go).
+func (s *apiSession) place(ctx context.Context, a apiAcct, app, target string) error {
+ in := struct {
+ Target string `json:"target"`
+ }{target}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/place"), in)
+ if err != nil {
+ return err
+ }
+ if status != http.StatusOK {
+ return apiErrorFromBody(status, body)
+ }
+ return nil
+}
diff --git a/apimode_test.go b/apimode_test.go
index 7fbff641b..fd1ea7257 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -2498,7 +2498,7 @@ func TestAPISessionCreateApp(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- res, err := s.createApp(context.Background(), apiAcct{Seg: "keymail"}, "paul")
+ res, err := s.createApp(context.Background(), apiAcct{Seg: "keymail"}, "paul", "")
if err != nil {
t.Fatalf("createApp: %v", err)
}
@@ -2509,7 +2509,7 @@ func TestAPISessionCreateApp(t *testing.T) {
t.Fatalf("result = %+v", res)
}
- if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae", Exact: true}, "paul"); err != nil {
+ if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae", Exact: true}, "paul", ""); err != nil {
t.Fatalf("createApp exact: %v", err)
}
if gotQuery != "exact=sqid" {
@@ -2517,14 +2517,56 @@ func TestAPISessionCreateApp(t *testing.T) {
}
status = http.StatusConflict
- if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "paul"); err == nil ||
+ if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "paul", ""); err == nil ||
err.Error() != "that name is taken" {
t.Fatalf("409 err = %v, want the server's sentence", err)
}
status = http.StatusNotFound
- if _, err := s.createApp(context.Background(), apiAcct{Seg: "nope"}, "paul"); err == nil ||
+ if _, err := s.createApp(context.Background(), apiAcct{Seg: "nope"}, "paul", ""); err == nil ||
!strings.Contains(err.Error(), "check -account,") {
t.Fatalf("404 err = %v, want create's account-only wording", err)
}
}
+
+// TestAPISessionCreateAppSendsPlace: the optional place argument (`carlos
+// apps create -place fleet/<f>`, 2026-08-06 ruling) travels in the create
+// body's "place" field, and an empty place is simply absent from the
+// request semantics the server reads as "no place given" either way — this
+// pins that the WIRE VALUE sent is exactly what was passed, not silently
+// dropped or renamed.
+func TestAPISessionCreateAppSendsPlace(t *testing.T) {
+ var gotPlace string
+ var sawPlaceKey bool
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var raw map[string]any
+ body, _ := io.ReadAll(r.Body)
+ json.Unmarshal(body, &raw)
+ _, sawPlaceKey = raw["place"]
+ var req struct {
+ Name string `json:"name"`
+ Place string `json:"place"`
+ }
+ json.Unmarshal(body, &req)
+ gotPlace = req.Place
+ json.NewEncoder(w).Encode(map[string]any{
+ "account": "bae", "app": req.Name, "url": "https://console.example/a/bae/apps/" + req.Name,
+ })
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "titogo", "fleet/pi"); err != nil {
+ t.Fatalf("createApp with place: %v", err)
+ }
+ if gotPlace != "fleet/pi" {
+ t.Fatalf("wire place = %q, want fleet/pi", gotPlace)
+ }
+
+ if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "unplaced", ""); err != nil {
+ t.Fatalf("createApp with no place: %v", err)
+ }
+ if sawPlaceKey && gotPlace != "" {
+ t.Fatalf("empty place must not travel as a non-empty value; got %q", gotPlace)
+ }
+}
diff --git a/apps.go b/apps.go
index d255eab49..9635537ce 100644
--- a/apps.go
+++ b/apps.go
@@ -30,10 +30,11 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
-const appsUsage = `usage: carlos apps <create|delete|restore> [flags] <app>
+const appsUsage = `usage: carlos apps <create|place|delete|restore> [flags] <app>
create -app <name> [flags] claim a new app name in an account, through
the console API — the same claim the console's New app
@@ -42,6 +43,21 @@ const appsUsage = `usage: carlos apps <create|delete|restore> [flags] <app>
form is not, and is the fallback. Signed-in mode only:
in direct bucket mode the first ship brings an app into
existence, and "console grant" is the box-side path.
+ -place fleet/<f> place the app on a customer fleet at the same
+ instant it is created — equivalent to running "apps place"
+ right after, but works even though the fresh app has zero
+ routes yet (the console stores placement on the app record
+ itself, not only on its routes). Validated the same way
+ -target below is: the grammar here, ownership server-side;
+ an invalid or foreign fleet refuses the WHOLE create.
+
+ place -app <name> -target fleet/<f>|"" repoint where an app's routes —
+ and its already-provisioned instances — should run: onto a
+ customer fleet ("fleet/<name>", a fleet your account owns),
+ or "" to clear back to platform-served. Every NEW instance
+ created afterwards inherits this target automatically;
+ every instance that already exists is repointed by this
+ call itself.
delete [flags] <app> move an app to the Trash: every route drops
(custom domains included), its name stays reserved, and it
@@ -88,6 +104,8 @@ func RunApps(args []string) {
switch args[0] {
case "create":
runAppsCreate(args[1:])
+ case "place":
+ runAppsPlace(args[1:])
case "delete":
runAppsDelete(args[1:])
case "restore":
@@ -330,9 +348,33 @@ func runAppsRestore(args []string) {
"carlos add <host> to serve it again\n", app)
}
+// validPlaceTarget is the client-side grammar gate for a fleet placement
+// target, shared by -place (apps create) and -target (apps place): checked
+// BEFORE any API call, on the same "a shape the server is certain to refuse
+// should cost nothing" principle validAppName's own doc comment states —
+// for -place at create time this matters doubly, since a wasted round trip
+// there would also spend the create verb's own scarce per-day budget on a
+// typo. "" is always valid (no placement, or clearing one); anything else
+// must parse as "fleet/<name>" per registry.FleetPlacement — the exact
+// grammar the server re-checks itself. This is belt only, never the buckle:
+// whether the named fleet exists and is owned by the caller's account is a
+// check only the server can make, and both server doors (handleAPICreateApp,
+// handleAPIPlace) still make it themselves.
+func validPlaceTarget(target string) error {
+ if target == "" {
+ return nil
+ }
+ if _, ok := registry.FleetPlacement(target); !ok {
+ return fmt.Errorf(`%q is not a usable placement target — want "" or "fleet/<name>" `+
+ "(1-32 characters of a-z, 0-9 and hyphen)", target)
+ }
+ return nil
+}
+
func runAppsCreate(args []string) {
fs := flag.NewFlagSet("apps create", flag.ExitOnError)
app := fs.String("app", "", "app name to claim (lowercase letters, digits, dashes)")
+ place := fs.String("place", "", `place the app on a customer fleet at create time — "" (default) or "fleet/<name>"`)
// Not accountFlags: that helper also registers -registry for direct-mode
// resolution, and this verb never resolves locally — the console resolves
// the account against the caller's own memberships.
@@ -341,14 +383,19 @@ func runAppsCreate(args []string) {
consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)")
fs.Parse(args)
if strings.TrimSpace(*app) == "" {
- log.Fatal("usage: carlos apps create -app <name> [-account <sqid|name>] [-account-id <sqid>] [-console <origin>]")
+ log.Fatal("usage: carlos apps create -app <name> [-place fleet/<f>] [-account <sqid|name>] " +
+ "[-account-id <sqid>] [-console <origin>]")
}
- // Client-side gate before the server's: the create limiter charges
- // ATTEMPTS, so a name the server is certain to 400 should cost a typo
- // nothing.
+ // Client-side gates before the server's: the create limiter charges
+ // ATTEMPTS, so a name — or a place value — the server is certain to
+ // refuse should cost a typo nothing.
if err := validAppName(strings.TrimSpace(*app)); err != nil {
log.Fatalf("apps create: %v", err)
}
+ place2 := strings.TrimSpace(*place)
+ if err := validPlaceTarget(place2); err != nil {
+ log.Fatalf("apps create: %v", err)
+ }
sess, err := resolveMode(*consoleFlag)
if err != nil {
@@ -366,11 +413,63 @@ func runAppsCreate(args []string) {
}
acct := apiAccount(acctRaw, acctIDRaw)
fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
- res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app))
+ res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2)
if err != nil {
log.Fatalf("apps create: %v", err)
}
fmt.Fprintf(stdout, "created app %s in account %s — it's yours\n", res.App, res.Account)
fmt.Fprintf(stdout, " open: %s\n", res.URL)
fmt.Fprintf(stdout, " ship: carlos ship -account %s -app %s ...\n", res.Account, res.App)
+ if place2 != "" {
+ fmt.Fprintf(stdout, " placed on %s\n", place2)
+ }
+}
+
+// runAppsPlace repoints where app's routes and already-provisioned
+// instances should run: `carlos apps place -app <name> -target
+// fleet/<f>|""`. Every field is client-side validated before the API call
+// exactly the way the server itself validates them, so a typo's cost is a
+// local refusal, not a spent write-rate-limit slot.
+func runAppsPlace(args []string) {
+ fs := flag.NewFlagSet("apps place", flag.ExitOnError)
+ app := fs.String("app", "", "app to place")
+ target := fs.String("target", "", `fleet target — "" clears placement, or "fleet/<name>"`)
+ // Not accountFlags: that helper also registers -registry for direct-mode
+ // resolution, and this verb never resolves locally — placement is
+ // console-tracked state, API mode only, same as apps create above.
+ 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; this is the flag an ambiguity error tells you to use")
+ consoleFlag := fs.String("console", "", "console to place through (default: the only logged-in one)")
+ fs.Parse(args)
+ if strings.TrimSpace(*app) == "" {
+ log.Fatal(`usage: carlos apps place -app <name> -target fleet/<f>|"" [-account <sqid|name>] ` +
+ "[-account-id <sqid>] [-console <origin>]")
+ }
+ if err := validAppName(strings.TrimSpace(*app)); err != nil {
+ log.Fatalf("apps place: %v", err)
+ }
+ target2 := strings.TrimSpace(*target)
+ if err := validPlaceTarget(target2); err != nil {
+ log.Fatalf("apps place: %v", err)
+ }
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("apps place works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Placement is console-tracked\n" +
+ "state: log in instead — carlos auth login -console <url>")
+ }
+ acct := apiAccount(acctRaw, acctIDRaw)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ if err := sess.place(context.Background(), acct, strings.TrimSpace(*app), target2); err != nil {
+ log.Fatalf("apps place: %v", err)
+ }
+ if target2 == "" {
+ fmt.Fprintf(stdout, "cleared placement for %s\n", strings.TrimSpace(*app))
+ return
+ }
+ fmt.Fprintf(stdout, "placed %s on %s\n", strings.TrimSpace(*app), target2)
}
diff --git a/fleets.go b/fleets.go
new file mode 100644
index 000000000..604b36a6a
--- /dev/null
+++ b/fleets.go
@@ -0,0 +1,308 @@
+// The `carlos fleets` verbs: mint an account-owned fleet, add/rotate/detach
+// its boxes, and list what an account owns. A fleet is a named group of
+// remote boxes (customer-fleets plan) that dial this console over a channel
+// identified by "<fleet>/<label>" — console-tracked state
+// (internal/registry's fleets/fleet_boxes tables), reached only through the
+// console's API doors (internal/console/fleets.go). There is no
+// bucket-direct fallback, the same posture `carlos apps create` takes: a
+// fleet cannot exist without a console to claim its name and mint its
+// boxes' tokens.
+//
+// `carlos apps place` — which repoints an EXISTING app onto a fleet, and
+// apps create's -place flag, which does the same at create time — live in
+// apps.go, not here: their subject is an app, and a fleet name is only ever
+// data in their request body (mirroring where the console itself put the
+// /place route — see fleets.go's package doc there).
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "os"
+ "strings"
+ "text/tabwriter"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+const fleetsUsage = `usage: carlos fleets <create|add-box|rotate-token|detach-box|ls> [flags]
+
+ create -bucket <bucket> [-account <sqid|name>] <name>
+ mint an account-owned fleet: a named group of remote boxes
+ that dial this console over a channel identified by
+ "<fleet>/<label>". <bucket> is the customer's OWN data
+ bucket, taken on trust that it already exists. The fleets
+ namespace is flat and global across every account — a name
+ once taken (by ANY account) cannot be reused, so the "that
+ name is taken" refusal on a retry does not necessarily mean
+ YOU took it.
+
+ add-box [-account <sqid|name>] <fleet> <label>
+ register one box to a fleet and mint its bearer token. THE
+ TOKEN IS PRINTED EXACTLY ONCE, HERE — the console never
+ stores or re-shows the plaintext, so losing this output
+ means rotate-token, not a re-run of add-box (which just
+ 409s on the label). Put it in the box's credential store
+ right away.
+ ROTATION IS NOT REVOCATION: rotating a token later (below)
+ refuses the box's NEXT dial-in with the OLD token but does
+ NOT evict a channel it already holds — a box whose
+ credential may be COMPROMISED needs detach-box, not
+ rotate-token.
+
+ rotate-token [-account <sqid|name>] <fleet> <label>
+ mint a FRESH token for a box that already exists, printed
+ exactly once, same as add-box. THIS REFUSES THE BOX'S NEXT
+ DIAL-IN with the old token, but does NOT evict a channel it
+ is already connected on — a box whose credential may be
+ COMPROMISED needs detach-box, not this.
+
+ detach-box [-account <sqid|name>] <fleet> <label>
+ the hard revocation rotate-token is not: flips the box to
+ "detached", which refuses its next dial-in AND closes any
+ channel it already holds. This is what a compromised box
+ needs.
+
+ ls [-account <sqid|name>]
+ list every fleet the account owns, with each box's label
+ and status.
+
+ -account <sqid|name> account the fleet belongs to (default: ops) —
+ resolved by the console against your own
+ memberships.
+ -account-id <sqid> account sqid, matched as a sqid ONLY — supersedes
+ -account
+ -console <origin> which logged-in console to use (only needed with
+ several)
+
+Every verb here is signed-in only (carlos auth login) — fleets are
+console-tracked state, with no bucket-direct fallback.
+`
+
+// RunFleets dispatches the fleets sub-verbs.
+func RunFleets(args []string) {
+ if len(args) == 0 {
+ fmt.Fprint(os.Stderr, fleetsUsage)
+ os.Exit(1)
+ }
+ switch args[0] {
+ case "create":
+ runFleetsCreate(args[1:])
+ case "add-box":
+ runFleetsAddBox(args[1:])
+ case "rotate-token":
+ runFleetsRotateToken(args[1:])
+ case "detach-box":
+ runFleetsDetachBox(args[1:])
+ case "ls":
+ runFleetsList(args[1:])
+ default:
+ fmt.Fprint(os.Stderr, fleetsUsage)
+ os.Exit(1)
+ }
+}
+
+// fleetsSignedInSession resolves API mode and fatals with a fleets-specific
+// message when this shell is in direct bucket mode — every fleets verb is
+// console-API-only, the same posture apps create takes (apps.go's
+// runAppsCreate): a fleet is console-tracked state with nothing in the
+// bucket to fall back to.
+func fleetsSignedInSession(consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("fleets works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). Fleets are console-tracked\n" +
+ "state with no bucket-direct fallback: unset the store env and run\n" +
+ "carlos auth login -console <url> instead.")
+ }
+ return sess
+}
+
+// validFleetName and validBoxLabel are the client-side gates for the two
+// name shapes every fleets verb below takes — checked BEFORE any API call,
+// on the same "a shape the server is certain to refuse should cost nothing"
+// principle apps.go's validAppName states. Both use
+// registry.ValidTunnelName: a fleet name and a box label are the two halves
+// of the "<fleet>/<label>" channel identity, and share its grammar exactly.
+func validFleetName(name string) error {
+ if registry.ValidTunnelName(name) {
+ return nil
+ }
+ return fmt.Errorf("%q is not a usable fleet name — 1-32 characters of a-z, 0-9 and hyphen", name)
+}
+
+func validBoxLabel(label string) error {
+ if registry.ValidTunnelName(label) {
+ return nil
+ }
+ return fmt.Errorf("%q is not a usable box label — 1-32 characters of a-z, 0-9 and hyphen", label)
+}
+
+func runFleetsCreate(args []string) {
+ fs := flag.NewFlagSet("fleets create", flag.ExitOnError)
+ bucket := fs.String("bucket", "", "the customer's own data bucket (must already exist)")
+ acctRaw := fs.String("account", "ops", "account (sqid or name) to own the fleet")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ consoleFlag := fs.String("console", "", "console to create it through (default: the only logged-in one)")
+ fs.Parse(args)
+ if fs.NArg() != 1 || strings.TrimSpace(*bucket) == "" {
+ log.Fatal("usage: carlos fleets create -bucket <bucket> [-account <sqid|name>] " +
+ "[-account-id <sqid>] [-console <origin>] <name>")
+ }
+ name := strings.TrimSpace(fs.Arg(0))
+ if err := validFleetName(name); err != nil {
+ log.Fatalf("fleets create: %v", err)
+ }
+ bucketV := strings.TrimSpace(*bucket)
+
+ sess := fleetsSignedInSession(*consoleFlag)
+ acct := apiAccount(acctRaw, acctIDRaw)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ f, err := sess.createFleet(context.Background(), acct, name, bucketV)
+ if err != nil {
+ log.Fatalf("fleets create: %v", err)
+ }
+ fmt.Fprintf(stdout, "created fleet %s (bucket %s)\n", f.Name, f.Bucket)
+ fmt.Fprintf(stdout, " add a box: carlos fleets add-box %s <label>\n", f.Name)
+}
+
+func runFleetsAddBox(args []string) {
+ fs := flag.NewFlagSet("fleets add-box", flag.ExitOnError)
+ acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ consoleFlag := fs.String("console", "", "console to add the box through (default: the only logged-in one)")
+ fs.Parse(args)
+ if fs.NArg() != 2 {
+ log.Fatal("usage: carlos fleets add-box [-account <sqid|name>] [-account-id <sqid>] " +
+ "[-console <origin>] <fleet> <label>")
+ }
+ fleet, label := strings.TrimSpace(fs.Arg(0)), strings.TrimSpace(fs.Arg(1))
+ if err := validFleetName(fleet); err != nil {
+ log.Fatalf("fleets add-box: %v", err)
+ }
+ if err := validBoxLabel(label); err != nil {
+ log.Fatalf("fleets add-box: %v", err)
+ }
+
+ sess := fleetsSignedInSession(*consoleFlag)
+ acct := apiAccount(acctRaw, acctIDRaw)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ token, err := sess.addFleetBox(context.Background(), acct, fleet, label)
+ if err != nil {
+ log.Fatalf("fleets add-box: %v", err)
+ }
+ printFleetToken(fleet, label, token, false)
+}
+
+func runFleetsRotateToken(args []string) {
+ fs := flag.NewFlagSet("fleets rotate-token", flag.ExitOnError)
+ acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ consoleFlag := fs.String("console", "", "console to rotate through (default: the only logged-in one)")
+ fs.Parse(args)
+ if fs.NArg() != 2 {
+ log.Fatal("usage: carlos fleets rotate-token [-account <sqid|name>] [-account-id <sqid>] " +
+ "[-console <origin>] <fleet> <label>")
+ }
+ fleet, label := strings.TrimSpace(fs.Arg(0)), strings.TrimSpace(fs.Arg(1))
+ if err := validFleetName(fleet); err != nil {
+ log.Fatalf("fleets rotate-token: %v", err)
+ }
+ if err := validBoxLabel(label); err != nil {
+ log.Fatalf("fleets rotate-token: %v", err)
+ }
+
+ sess := fleetsSignedInSession(*consoleFlag)
+ acct := apiAccount(acctRaw, acctIDRaw)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ token, err := sess.rotateFleetBoxToken(context.Background(), acct, fleet, label)
+ if err != nil {
+ log.Fatalf("fleets rotate-token: %v", err)
+ }
+ printFleetToken(fleet, label, token, true)
+}
+
+// printFleetToken is the ONE place a fleet box token reaches stdout — for
+// both add-box and rotate-token, so the two verbs can never drift apart on
+// the warning that matters most (carried from Task 3's review, per this
+// task's brief): rotating (or minting) a token refuses a box's NEXT dial-in
+// with the credential just replaced, but does NOT evict a channel the box
+// already holds — a COMPROMISED box needs `fleets detach-box`, not
+// rotate-token. Printed exactly once, immediately after the token itself,
+// on every call — never logged, never re-shown.
+func printFleetToken(fleet, label, token string, rotated bool) {
+ fmt.Fprintf(stdout, "%s/%s token: %s\n", fleet, label, token)
+ fmt.Fprintln(stdout, " (shown once — the console does not store or re-show the plaintext)")
+ if rotated {
+ fmt.Fprintln(stdout, " ROTATION IS NOT REVOCATION: this refuses the box's NEXT dial-in with the OLD")
+ fmt.Fprintln(stdout, " token, but does NOT evict a channel it already holds — a COMPROMISED box needs")
+ fmt.Fprintln(stdout, " `carlos fleets detach-box`, not another rotate-token.")
+ return
+ }
+ fmt.Fprintln(stdout, " NOTE: rotating this token later (rotate-token) refuses the box's next dial-in but")
+ fmt.Fprintln(stdout, " does NOT evict a live session — a COMPROMISED box needs `fleets detach-box` instead.")
+}
+
+func runFleetsDetachBox(args []string) {
+ fs := flag.NewFlagSet("fleets detach-box", flag.ExitOnError)
+ acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ consoleFlag := fs.String("console", "", "console to detach through (default: the only logged-in one)")
+ fs.Parse(args)
+ if fs.NArg() != 2 {
+ log.Fatal("usage: carlos fleets detach-box [-account <sqid|name>] [-account-id <sqid>] " +
+ "[-console <origin>] <fleet> <label>")
+ }
+ fleet, label := strings.TrimSpace(fs.Arg(0)), strings.TrimSpace(fs.Arg(1))
+ if err := validFleetName(fleet); err != nil {
+ log.Fatalf("fleets detach-box: %v", err)
+ }
+ if err := validBoxLabel(label); err != nil {
+ log.Fatalf("fleets detach-box: %v", err)
+ }
+
+ sess := fleetsSignedInSession(*consoleFlag)
+ acct := apiAccount(acctRaw, acctIDRaw)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ if err := sess.detachFleetBox(context.Background(), acct, fleet, label); err != nil {
+ log.Fatalf("fleets detach-box: %v", err)
+ }
+ fmt.Fprintf(stdout, "detached %s/%s — its next dial-in is refused and any live channel is closed\n", fleet, label)
+}
+
+func runFleetsList(args []string) {
+ fs := flag.NewFlagSet("fleets ls", flag.ExitOnError)
+ acctRaw := fs.String("account", "ops", "account (sqid or name) to list fleets for")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)")
+ fs.Parse(args)
+
+ sess := fleetsSignedInSession(*consoleFlag)
+ acct := apiAccount(acctRaw, acctIDRaw)
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ fleets, err := sess.listFleets(context.Background(), acct)
+ if err != nil {
+ log.Fatalf("fleets ls: %v", err)
+ }
+ if len(fleets) == 0 {
+ fmt.Fprintln(stdout, "no fleets")
+ return
+ }
+ w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
+ fmt.Fprintln(w, "FLEET\tBUCKET\tBOX\tSTATUS")
+ for _, f := range fleets {
+ if len(f.Boxes) == 0 {
+ fmt.Fprintf(w, "%s\t%s\t-\t-\n", f.Name, f.Bucket)
+ continue
+ }
+ for _, b := range f.Boxes {
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", f.Name, f.Bucket, b.Label, b.Status)
+ }
+ }
+ w.Flush()
+}
diff --git a/fleets_cli_test.go b/fleets_cli_test.go
new file mode 100644
index 000000000..e75db593f
--- /dev/null
+++ b/fleets_cli_test.go
@@ -0,0 +1,606 @@
+// Tests for the `carlos fleets` verbs and `carlos apps place` — the CLI
+// door onto internal/console/fleets.go (Task 7). Three layers:
+//
+// - apiSession-level tests drive the six new client methods (apimode.go)
+// directly against an httptest fake console: path, body, and wire-shape
+// round trips, with no flag parsing or os.Exit in the loop.
+// - RunFleets/RunApps-level tests drive the full verb (flag parsing,
+// mode resolution, printing) with GOOD arguments only, against the
+// same kind of fake console, credentials written straight to disk
+// (mirroring auth_e2e_test.go's TestAccountsCreateUsesTheLoggedInIdentity
+// rather than a real login round trip) — this is what proves the token
+// really reaches stdout exactly once and the detach-not-rotate warning
+// is really in the output.
+// - one subprocess test (TestFleetsAndPlaceCLIUsageErrors, buildCarlos-
+// style like auth_e2e_test.go) drives every verb's BAD-argument path:
+// those call log.Fatal/os.Exit, which would kill the test binary if
+// driven in-process (the reason instances_cli_test.go gives for
+// skipping runInstancesEnable's own argv parsing) — a real subprocess
+// is the only way to observe "exits nonzero and says usage" without
+// restructuring the verbs around it.
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// ---- pure grammar gates -----------------------------------------------------
+
+func TestValidFleetNameAndBoxLabel(t *testing.T) {
+ for _, good := range []string{"acme", "pi-1", "a", strings.Repeat("a", 32)} {
+ if err := validFleetName(good); err != nil {
+ t.Errorf("validFleetName(%q) = %v, want nil", good, err)
+ }
+ if err := validBoxLabel(good); err != nil {
+ t.Errorf("validBoxLabel(%q) = %v, want nil", good, err)
+ }
+ }
+ for _, bad := range []string{"", "Acme", "has spaces", "under_score", strings.Repeat("a", 33)} {
+ if err := validFleetName(bad); err == nil {
+ t.Errorf("validFleetName(%q) = nil, want an error", bad)
+ }
+ if err := validBoxLabel(bad); err == nil {
+ t.Errorf("validBoxLabel(%q) = nil, want an error", bad)
+ }
+ }
+}
+
+func TestValidPlaceTarget(t *testing.T) {
+ for _, good := range []string{"", "fleet/pi", "fleet/acme-1"} {
+ if err := validPlaceTarget(good); err != nil {
+ t.Errorf("validPlaceTarget(%q) = %v, want nil", good, err)
+ }
+ }
+ for _, bad := range []string{"eu", "fleet/", "fleet/Acme", "pi"} {
+ if err := validPlaceTarget(bad); err == nil {
+ t.Errorf("validPlaceTarget(%q) = nil, want an error", bad)
+ }
+ }
+}
+
+// ---- apiSession-level: path + body + wire-shape round trips ---------------
+
+// TestAPISessionFleetCreateAndList: createFleet posts to the literal `new`
+// door with name+bucket in the body and unmarshals the "fleet" wrapper;
+// listFleets GETs the account door and unmarshals "fleets".
+func TestAPISessionFleetCreateAndList(t *testing.T) {
+ var gotPath, gotMethod string
+ var gotBody map[string]string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod = r.URL.Path, r.Method
+ if r.Method == http.MethodPost {
+ json.NewDecoder(r.Body).Decode(&gotBody)
+ json.NewEncoder(w).Encode(map[string]any{
+ "fleet": map[string]any{"name": gotBody["name"], "bucket": gotBody["bucket"], "boxes": []any{}},
+ })
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "fleets": []map[string]any{
+ {"name": "acme", "bucket": "acme-data", "boxes": []map[string]any{
+ {"label": "pi-1", "status": "active"},
+ }},
+ },
+ })
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ f, err := s.createFleet(context.Background(), apiAcct{Seg: "bab"}, "acme", "acme-data")
+ if err != nil {
+ t.Fatalf("createFleet: %v", err)
+ }
+ if gotPath != "/api/cli/fleets/bab/new" || gotMethod != http.MethodPost {
+ t.Fatalf("request = %s %s, want POST /api/cli/fleets/bab/new", gotMethod, gotPath)
+ }
+ if gotBody["name"] != "acme" || gotBody["bucket"] != "acme-data" {
+ t.Fatalf("body = %v", gotBody)
+ }
+ if f.Name != "acme" || f.Bucket != "acme-data" || len(f.Boxes) != 0 {
+ t.Fatalf("createFleet result = %+v", f)
+ }
+
+ fleets, err := s.listFleets(context.Background(), apiAcct{Seg: "bab"})
+ if err != nil {
+ t.Fatalf("listFleets: %v", err)
+ }
+ if gotPath != "/api/cli/fleets/bab" || gotMethod != http.MethodGet {
+ t.Fatalf("request = %s %s, want GET /api/cli/fleets/bab", gotMethod, gotPath)
+ }
+ if len(fleets) != 1 || fleets[0].Name != "acme" || len(fleets[0].Boxes) != 1 || fleets[0].Boxes[0].Label != "pi-1" {
+ t.Fatalf("listFleets result = %+v", fleets)
+ }
+}
+
+// TestAPISessionFleetCreateExactAccountID: -account-id travels as
+// exact=sqid, same as every other apiPath-built call.
+func TestAPISessionFleetCreateExactAccountID(t *testing.T) {
+ var gotQuery string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotQuery = r.URL.RawQuery
+ json.NewEncoder(w).Encode(map[string]any{"fleet": map[string]any{"name": "x", "bucket": "y", "boxes": []any{}}})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ if _, err := s.createFleet(context.Background(), apiAcct{Seg: "bab", Exact: true}, "x", "y"); err != nil {
+ t.Fatalf("createFleet: %v", err)
+ }
+ if gotQuery != "exact=sqid" {
+ t.Fatalf("query = %q, want exact=sqid", gotQuery)
+ }
+}
+
+// TestAPISessionFleetCreateDuplicateName: the server's 409 sentence surfaces
+// verbatim, same convention as apps create's duplicate refusal.
+func TestAPISessionFleetCreateDuplicateName(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]string{"error": "that name is taken"})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ if _, err := s.createFleet(context.Background(), apiAcct{Seg: "bab"}, "acme", "d"); err == nil ||
+ err.Error() != "that name is taken" {
+ t.Fatalf("createFleet duplicate = %v, want the server's sentence", err)
+ }
+}
+
+// TestAPISessionFleetAddBoxRotateDetach: add-box and rotate-token both POST
+// to their own path and return the plaintext token in the "token" field;
+// detach-box POSTs and returns no token at all.
+func TestAPISessionFleetAddBoxRotateDetach(t *testing.T) {
+ var gotPath, gotLabel string
+ step := "add"
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ switch step {
+ case "add":
+ var req struct{ Label string }
+ json.NewDecoder(r.Body).Decode(&req)
+ gotLabel = req.Label
+ json.NewEncoder(w).Encode(map[string]any{"label": req.Label, "token": "tok-first-64hex"})
+ case "rotate":
+ json.NewEncoder(w).Encode(map[string]any{"token": "tok-second-64hex"})
+ case "detach":
+ json.NewEncoder(w).Encode(map[string]any{})
+ }
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ acct := apiAcct{Seg: "bab"}
+
+ tok, err := s.addFleetBox(context.Background(), acct, "acme", "pi-1")
+ if err != nil {
+ t.Fatalf("addFleetBox: %v", err)
+ }
+ if gotPath != "/api/cli/fleets/bab/acme/boxes" || gotLabel != "pi-1" || tok != "tok-first-64hex" {
+ t.Fatalf("addFleetBox: path=%s label=%s token=%s", gotPath, gotLabel, tok)
+ }
+
+ step = "rotate"
+ tok, err = s.rotateFleetBoxToken(context.Background(), acct, "acme", "pi-1")
+ if err != nil {
+ t.Fatalf("rotateFleetBoxToken: %v", err)
+ }
+ if gotPath != "/api/cli/fleets/bab/acme/boxes/pi-1/rotate" || tok != "tok-second-64hex" {
+ t.Fatalf("rotateFleetBoxToken: path=%s token=%s", gotPath, tok)
+ }
+
+ step = "detach"
+ if err := s.detachFleetBox(context.Background(), acct, "acme", "pi-1"); err != nil {
+ t.Fatalf("detachFleetBox: %v", err)
+ }
+ if gotPath != "/api/cli/fleets/bab/acme/boxes/pi-1/detach" {
+ t.Fatalf("detachFleetBox path = %s", gotPath)
+ }
+}
+
+// TestAPISessionFleetDoorsSurfaceServerErrors: a non-200 from any of the
+// three box doors surfaces the server's own message, not a swallowed one.
+func TestAPISessionFleetDoorsSurfaceServerErrors(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.addFleetBox(context.Background(), acct, "ghost", "x"); err == nil {
+ t.Fatal("addFleetBox against a 404 must error")
+ }
+ if _, err := s.rotateFleetBoxToken(context.Background(), acct, "ghost", "x"); err == nil {
+ t.Fatal("rotateFleetBoxToken against a 404 must error")
+ }
+ if err := s.detachFleetBox(context.Background(), acct, "ghost", "x"); err == nil {
+ t.Fatal("detachFleetBox against a 404 must error")
+ }
+}
+
+// TestAPISessionPlace: place POSTs the target in the body to .../place, for
+// both a fleet target and the clearing "".
+func TestAPISessionPlace(t *testing.T) {
+ var gotPath string
+ var gotTarget string
+ var sawTarget bool
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ var req struct {
+ Target string `json:"target"`
+ }
+ json.NewDecoder(r.Body).Decode(&req)
+ gotTarget, sawTarget = req.Target, true
+ json.NewEncoder(w).Encode(map[string]any{})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", "fleet/pi"); err != nil {
+ t.Fatalf("place: %v", err)
+ }
+ if gotPath != "/api/cli/apps/bab/titogo/place" || !sawTarget || gotTarget != "fleet/pi" {
+ t.Fatalf("place request: path=%s target=%q sawTarget=%v", gotPath, gotTarget, sawTarget)
+ }
+
+ if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", ""); err != nil {
+ t.Fatalf("place clear: %v", err)
+ }
+ if gotTarget != "" {
+ t.Fatalf("clearing place: target=%q, want empty", gotTarget)
+ }
+}
+
+// TestAPISessionPlaceSurfacesRefusal: an invalid-shape 400 and a
+// foreign-fleet 404 both surface through place unchanged.
+func TestAPISessionPlaceSurfacesRefusal(t *testing.T) {
+ status := http.StatusBadRequest
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if status == http.StatusNotFound {
+ http.NotFound(w, r)
+ return
+ }
+ w.WriteHeader(status)
+ json.NewEncoder(w).Encode(map[string]string{"error": `invalid target — want "" or "fleet/<name>"`})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", "eu"); err == nil ||
+ !strings.Contains(err.Error(), "invalid target") {
+ t.Fatalf("place with a bad shape = %v, want the server's message", err)
+ }
+
+ status = http.StatusNotFound
+ if err := s.place(context.Background(), apiAcct{Seg: "bab"}, "titogo", "fleet/ghost"); err == nil {
+ t.Fatal("place onto a foreign/unknown fleet must error")
+ }
+}
+
+// ---- RunFleets / RunApps-level: full verb, good args, real printing -------
+
+// fakeFleetsConsole answers the CLI endpoints RunFleets/apps-create/apps-place
+// reach, stateful enough to prove add-box then rotate-token return DISTINCT
+// tokens and that create-with-place's "place" field actually arrives.
+type fakeFleetsConsole struct {
+ ts *httptest.Server
+
+ tokenSeq atomic.Int64
+
+ lastAuth atomic.Value // string
+
+ // what the last relevant call carried, for assertions.
+ lastCreateBucket atomic.Value // string
+ lastCreatePlace atomic.Value // string
+ lastPlaceTarget atomic.Value // string
+ lastDetachPath atomic.Value // string
+}
+
+func newFakeFleetsConsole(t *testing.T) *fakeFleetsConsole {
+ t.Helper()
+ fc := &fakeFleetsConsole{}
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /api/cli/fleets/{account}/new", func(w http.ResponseWriter, r *http.Request) {
+ fc.lastAuth.Store(r.Header.Get("Authorization"))
+ var req struct{ Name, Bucket string }
+ json.NewDecoder(r.Body).Decode(&req)
+ fc.lastCreateBucket.Store(req.Bucket)
+ json.NewEncoder(w).Encode(map[string]any{
+ "fleet": map[string]any{"name": req.Name, "bucket": req.Bucket, "boxes": []any{}},
+ })
+ })
+ mux.HandleFunc("GET /api/cli/fleets/{account}", func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{
+ "fleets": []map[string]any{
+ {"name": "acme", "bucket": "acme-data", "boxes": []map[string]any{
+ {"label": "pi-1", "status": "active"},
+ {"label": "pi-2", "status": "detached"},
+ }},
+ },
+ })
+ })
+ mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes", func(w http.ResponseWriter, r *http.Request) {
+ var req struct{ Label string }
+ json.NewDecoder(r.Body).Decode(&req)
+ n := fc.tokenSeq.Add(1)
+ json.NewEncoder(w).Encode(map[string]any{
+ "label": req.Label, "token": tokenFor(n),
+ })
+ })
+ mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/rotate", func(w http.ResponseWriter, r *http.Request) {
+ n := fc.tokenSeq.Add(1)
+ json.NewEncoder(w).Encode(map[string]any{"token": tokenFor(n)})
+ })
+ mux.HandleFunc("POST /api/cli/fleets/{account}/{fleet}/boxes/{label}/detach", func(w http.ResponseWriter, r *http.Request) {
+ fc.lastDetachPath.Store(r.URL.Path)
+ json.NewEncoder(w).Encode(map[string]any{})
+ })
+ mux.HandleFunc("POST /api/cli/apps/{account}/new", func(w http.ResponseWriter, r *http.Request) {
+ var req struct{ Name, Place string }
+ json.NewDecoder(r.Body).Decode(&req)
+ fc.lastCreatePlace.Store(req.Place)
+ json.NewEncoder(w).Encode(map[string]any{
+ "account": "bab", "app": req.Name, "url": fc.ts.URL + "/a/bab/apps/" + req.Name,
+ })
+ })
+ mux.HandleFunc("POST /api/cli/apps/{account}/{app}/place", func(w http.ResponseWriter, r *http.Request) {
+ var req struct{ Target string }
+ json.NewDecoder(r.Body).Decode(&req)
+ fc.lastPlaceTarget.Store(req.Target)
+ json.NewEncoder(w).Encode(map[string]any{})
+ })
+ fc.ts = httptest.NewServer(mux)
+ t.Cleanup(fc.ts.Close)
+ return fc
+}
+
+// tokenFor mints a distinguishable fake token per call so a test can prove
+// add-box and a later rotate-token did NOT return the same value.
+func tokenFor(n int64) string {
+ return strings.Repeat("a", 60) + "-" + string(rune('0'+n%10))
+}
+
+// writeTestCredential drops a credential straight on disk for console at
+// url with a fixed bearer, the same shortcut
+// TestAccountsCreateUsesTheLoggedInIdentity (auth_e2e_test.go) takes to
+// avoid a login round trip in a test that isn't about login.
+func writeTestCredential(t *testing.T, path, url string) {
+ t.Helper()
+ f := &credentialFile{Version: 1, Credentials: []credential{{
+ Console: url, Address: "paul@keymail.dev", Token: "cli-token",
+ CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().Add(24 * time.Hour).UTC(),
+ }}}
+ raw, err := json.Marshal(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, raw, 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// fleetsCLIFixture wires up a fake console plus a credential pointed at it,
+// entirely in-process (no subprocess, no real login flow) — the "package
+// level tests against httptest console servers" shape.
+func fleetsCLIFixture(t *testing.T) *fakeFleetsConsole {
+ 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 := newFakeFleetsConsole(t)
+ writeTestCredential(t, creds, fc.ts.URL)
+ return fc
+}
+
+// TestRunFleetsCreateHitsRightPathWithBody drives `carlos fleets create`
+// with good args end to end (flag parsing included) and checks the console
+// saw the right bucket and the verb printed the fleet name back.
+func TestRunFleetsCreateHitsRightPathWithBody(t *testing.T) {
+ fc := fleetsCLIFixture(t)
+ buf := captureStdout(t)
+
+ RunFleets([]string{"create", "-bucket", "acme-data", "-account", "bab", "acme"})
+
+ if got := fc.lastCreateBucket.Load(); got != "acme-data" {
+ t.Fatalf("console saw bucket %v, want acme-data", got)
+ }
+ if !strings.Contains(buf.String(), "created fleet acme") {
+ t.Fatalf("stdout = %q, want it to confirm the created fleet", buf.String())
+ }
+}
+
+// TestRunFleetsAddBoxPrintsTokenOnceWithDetachWarning: add-box's token
+// appears in stdout EXACTLY ONCE, and the output carries the detach-not-
+// rotate warning verbatim (Task 3's review finding, carried into this
+// task's brief).
+func TestRunFleetsAddBoxPrintsTokenOnceWithDetachWarning(t *testing.T) {
+ fleetsCLIFixture(t)
+ buf := captureStdout(t)
+
+ RunFleets([]string{"add-box", "-account", "bab", "acme", "pi-1"})
+
+ out := buf.String()
+ tok := tokenFor(1)
+ if n := strings.Count(out, tok); n != 1 {
+ t.Fatalf("token %q appeared %d times in stdout, want exactly 1:\n%s", tok, n, out)
+ }
+ if !strings.Contains(out, "detach-box") {
+ t.Fatalf("stdout missing the detach-box pointer:\n%s", out)
+ }
+ if !strings.Contains(strings.ToLower(out), "not evict") {
+ t.Fatalf("stdout missing the does-NOT-evict-a-live-session warning:\n%s", out)
+ }
+}
+
+// TestRunFleetsRotateTokenPrintsFreshTokenOnceWithWarning: rotate-token's
+// output carries the SAME warning as add-box's (rotation refuses the next
+// dial-in but does not evict a live channel), and a token distinct from a
+// prior add-box's.
+func TestRunFleetsRotateTokenPrintsFreshTokenOnceWithWarning(t *testing.T) {
+ fleetsCLIFixture(t)
+ addBuf := captureStdout(t)
+ RunFleets([]string{"add-box", "-account", "bab", "acme", "pi-1"})
+ firstToken := tokenFor(1)
+ if !strings.Contains(addBuf.String(), firstToken) {
+ t.Fatalf("add-box did not print its own token:\n%s", addBuf.String())
+ }
+
+ rotBuf := captureStdout(t)
+ RunFleets([]string{"rotate-token", "-account", "bab", "acme", "pi-1"})
+ out := rotBuf.String()
+ secondToken := tokenFor(2)
+ if n := strings.Count(out, secondToken); n != 1 {
+ t.Fatalf("rotated token %q appeared %d times, want exactly 1:\n%s", secondToken, n, out)
+ }
+ if strings.Contains(out, firstToken) {
+ t.Fatalf("rotate-token output leaked the OLD token:\n%s", out)
+ }
+ if !strings.Contains(out, "detach-box") || !strings.Contains(strings.ToLower(out), "not evict") {
+ t.Fatalf("rotate-token stdout missing the detach-not-rotate warning:\n%s", out)
+ }
+}
+
+// TestRunFleetsDetachBoxHitsRightPath: detach-box reaches the right door and
+// confirms in its own words (not the token-print wording).
+func TestRunFleetsDetachBoxHitsRightPath(t *testing.T) {
+ fc := fleetsCLIFixture(t)
+ buf := captureStdout(t)
+
+ RunFleets([]string{"detach-box", "-account", "bab", "acme", "pi-1"})
+
+ if got := fc.lastDetachPath.Load(); got != "/api/cli/fleets/bab/acme/boxes/pi-1/detach" {
+ t.Fatalf("detach path = %v, want the labelled detach door", got)
+ }
+ if !strings.Contains(buf.String(), "detached acme/pi-1") {
+ t.Fatalf("stdout = %q, want it to confirm the detach", buf.String())
+ }
+}
+
+// TestRunFleetsListPrintsEveryBox: `carlos fleets ls` prints one row per
+// box, including a detached one, from the fake console's fixed listing.
+func TestRunFleetsListPrintsEveryBox(t *testing.T) {
+ fleetsCLIFixture(t)
+ buf := captureStdout(t)
+
+ RunFleets([]string{"ls", "-account", "bab"})
+
+ out := buf.String()
+ for _, want := range []string{"acme", "acme-data", "pi-1", "active", "pi-2", "detached"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("fleets ls stdout missing %q:\n%s", want, out)
+ }
+ }
+}
+
+// TestRunAppsCreateWithPlaceSendsPlaceField: `carlos apps create -place
+// fleet/<f>` reaches the console with place in the body (the 2026-08-06
+// ruling's CLI surface).
+func TestRunAppsCreateWithPlaceSendsPlaceField(t *testing.T) {
+ fc := fleetsCLIFixture(t)
+ buf := captureStdout(t)
+
+ RunApps([]string{"create", "-app", "titogo", "-place", "fleet/pi", "-account", "bab"})
+
+ if got := fc.lastCreatePlace.Load(); got != "fleet/pi" {
+ t.Fatalf("console saw place %v, want fleet/pi", got)
+ }
+ if !strings.Contains(buf.String(), "placed on fleet/pi") {
+ t.Fatalf("stdout = %q, want it to confirm the placement", buf.String())
+ }
+}
+
+// TestRunAppsPlaceHitsRightPathAndClears: `carlos apps place` sends the
+// target, and passing "" clears it — both print their own confirmation.
+func TestRunAppsPlaceHitsRightPathAndClears(t *testing.T) {
+ fc := fleetsCLIFixture(t)
+ buf := captureStdout(t)
+
+ RunApps([]string{"place", "-app", "titogo", "-target", "fleet/pi", "-account", "bab"})
+ if got := fc.lastPlaceTarget.Load(); got != "fleet/pi" {
+ t.Fatalf("console saw target %v, want fleet/pi", got)
+ }
+ if !strings.Contains(buf.String(), "placed titogo on fleet/pi") {
+ t.Fatalf("stdout = %q, want the placed confirmation", buf.String())
+ }
+
+ buf2 := captureStdout(t)
+ RunApps([]string{"place", "-app", "titogo", "-target", "", "-account", "bab"})
+ if got := fc.lastPlaceTarget.Load(); got != "" {
+ t.Fatalf("console saw target %v, want \"\" (cleared)", got)
+ }
+ if !strings.Contains(buf2.String(), "cleared placement for titogo") {
+ t.Fatalf("stdout = %q, want the cleared confirmation", buf2.String())
+ }
+}
+
+// ---- subprocess: the usage-error / bad-argument paths ---------------------
+
+// TestFleetsAndPlaceCLIUsageErrors drives every fleets verb and apps place
+// with missing or malshaped arguments as a real subprocess (buildCarlos,
+// same helper auth_e2e_test.go's TestAuthCLI uses) — the ONLY way to observe
+// a log.Fatal/os.Exit refusal without killing the test binary. Every case
+// here must exit nonzero without ever reaching the network: carlosTestEnv
+// masks CARLOS_CREDENTIALS/HOME/CARLOS_CONSOLE and cmd.Dir is a scratch dir
+// (CLAUDE.md's rule — a subprocess that inherited a real login has minted
+// real accounts on the flagship before), so a case that DID try to dial out
+// would fail on "not logged in", not silently succeed.
+func TestFleetsAndPlaceCLIUsageErrors(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 // substring the combined output must contain
+ }{
+ {"fleets no args", []string{"fleets"}, "usage: carlos fleets"},
+ {"fleets bogus verb", []string{"fleets", "bogus"}, "usage: carlos fleets"},
+ {"fleets create no args", []string{"fleets", "create"}, "usage: carlos fleets create"},
+ {"fleets create missing bucket", []string{"fleets", "create", "-account", "bab", "acme"}, "usage: carlos fleets create"},
+ {"fleets create bad name", []string{"fleets", "create", "-bucket", "d", "-account", "bab", "Bad Name"}, "not a usable fleet name"},
+ {"fleets add-box no args", []string{"fleets", "add-box"}, "usage: carlos fleets add-box"},
+ {"fleets add-box missing label", []string{"fleets", "add-box", "acme"}, "usage: carlos fleets add-box"},
+ {"fleets add-box bad label", []string{"fleets", "add-box", "acme", "Bad Label"}, "not a usable box label"},
+ {"fleets rotate-token no args", []string{"fleets", "rotate-token"}, "usage: carlos fleets rotate-token"},
+ {"fleets detach-box no args", []string{"fleets", "detach-box"}, "usage: carlos fleets detach-box"},
+ {"apps place missing app", []string{"apps", "place", "-target", "fleet/pi"}, "usage: carlos apps place"},
+ {"apps place bad target shape", []string{"apps", "place", "-app", "titogo", "-target", "eu"}, "not a usable placement target"},
+ {"apps create bad place shape", []string{"apps", "create", "-app", "titogo", "-place", "eu"}, "not a usable placement target"},
+ } {
+ 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/refusal) 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 fb8b01722..57b161ef1 100644
--- a/main.go
+++ b/main.go
@@ -49,6 +49,8 @@ func main() {
RunAccounts(args)
case "apps":
RunApps(args)
+ case "fleets":
+ RunFleets(args)
case "env":
RunEnv(args)
case "secrets":
@@ -109,17 +111,26 @@ commands:
auth login | whoami | logout — who this terminal is
(headless? don't log in: pass -owner instead)
accounts create | list | migrate — the tenancy primitive
- apps create | delete | restore — claim a new app
- name (console API only, rate limited per
- address per day; the console form is not),
- trash an app for 30 days (routes drop
- everywhere; refused while flagged production),
- or undo that within the window (no routes come
- back; carlos add <host> after); delete/restore
- run signed-in via the console or headless
- against the bucket (-account-id required
- headless); no purge here — "Delete now" is
- console-only
+ apps create | place | delete | restore — claim a new
+ app name (console API only, rate limited per
+ address per day; the console form is not,
+ -place fleet/<f> places it the same instant),
+ repoint an app's routes/instances onto a
+ customer fleet or back to "", trash an app for
+ 30 days (routes drop everywhere; refused while
+ flagged production), or undo that within the
+ window (no routes come back; carlos add <host>
+ after); delete/restore run signed-in via the
+ console or headless against the bucket
+ (-account-id required headless); no purge here
+ — "Delete now" is console-only
+ fleets create | add-box | rotate-token | detach-box |
+ ls — account-owned groups of remote boxes that
+ dial this console (customer fleets); console
+ API only, no bucket-direct fallback. Box
+ tokens print exactly once, at mint/rotate;
+ rotate-token does NOT evict a live channel — a
+ compromised box needs detach-box
env set | unset | list — plain per-app config vars
secrets set | unset | list | genkey — sealed per-app secrets
ship | promote | channels publish and promote releases (-account <sqid|name>)