carlos / platform Public

cli: apps create and deploy onboarding send a timezone, take --region, print the region line (nearest-region §11)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev d1c41c15ed258df72012b92f4677ace1d35b5a04 parent edffc4f
8 files changed, +238 −16
  • apimode.go +12 −2
  • apimode_test.go +6 −6
  • apps.go +5 −2
  • apps_region.go +29 −0
  • apps_region_test.go +178 −0
  • apps_test.go +1 −1
  • deploy.go +6 −4
  • deploy_wizard_test.go +1 −1
diff --git a/apimode.go b/apimode.go
index 85c63470d..ba79a46c8 100644
--- a/apimode.go
+++ b/apimode.go
@@ -963,6 +963,14 @@ type apiCreateAppResult struct {
Placement string `json:"placement"`
ControlPlane bool `json:"control_plane"`
BuildProbe string `json:"build_probe"`
+
+ // The app region the console STORED (nearest-region spec §9.1), under
+ // its name. Absent from an older console, which the CLI reads as "no
+ // app region recorded".
+ Region string `json:"region"`
+ RegionName string `json:"region_name"`
+ RegionSource string `json:"region_source"`
+ RegionsAvailable int `json:"regions_available"`
}
// createApp claims one app name through the console — the API door onto the
@@ -992,13 +1000,15 @@ type apiCreateAppResult struct {
// buildProbe is grammar-checked client-side before this is ever called
// (validBuildProbe, apps.go), same "a shape the server is certain to
// refuse should cost nothing" principle as place.
-func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string, controlPlane bool, buildProbe string) (apiCreateAppResult, error) {
+func (s *apiSession) createApp(ctx context.Context, a apiAcct, app, place string, controlPlane bool, buildProbe, region, timezone string) (apiCreateAppResult, error) {
in := struct {
Name string `json:"name"`
Place string `json:"place,omitempty"`
ControlPlane bool `json:"control_plane,omitempty"`
BuildProbe string `json:"build_probe,omitempty"`
- }{app, place, controlPlane, buildProbe}
+ Region string `json:"region,omitempty"`
+ Timezone string `json:"timezone,omitempty"`
+ }{app, place, controlPlane, buildProbe, region, timezone}
status, body, err := s.postJSON(ctx, apiPath(a, "new", ""), in)
if err != nil {
return apiCreateAppResult{}, err
diff --git a/apimode_test.go b/apimode_test.go
index 7357bcf8d..a5176d5e3 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -3038,7 +3038,7 @@ func TestAPISessionCreateApp(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- res, err := s.createApp(context.Background(), apiAcct{Seg: "keymail"}, "paul", "", false, "")
+ res, err := s.createApp(context.Background(), apiAcct{Seg: "keymail"}, "paul", "", false, "", "", "")
if err != nil {
t.Fatalf("createApp: %v", err)
}
@@ -3049,7 +3049,7 @@ func TestAPISessionCreateApp(t *testing.T) {
t.Fatalf("result = %+v", res)
}
- if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae", Exact: true}, "paul", "", false, ""); err != nil {
+ if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae", Exact: true}, "paul", "", false, "", "", ""); err != nil {
t.Fatalf("createApp exact: %v", err)
}
if gotQuery != "exact=sqid" {
@@ -3057,13 +3057,13 @@ func TestAPISessionCreateApp(t *testing.T) {
}
status = http.StatusConflict
- if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "paul", "", false, ""); err == nil ||
+ if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "paul", "", false, "", "", ""); 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", "", false, ""); err == nil ||
+ if _, err := s.createApp(context.Background(), apiAcct{Seg: "nope"}, "paul", "", false, "", "", ""); err == nil ||
!strings.Contains(err.Error(), "check --account,") {
t.Fatalf("404 err = %v, want create's account-only wording", err)
}
@@ -3096,14 +3096,14 @@ func TestAPISessionCreateAppSendsPlace(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "titogo", "fleet/pi", false, ""); err != nil {
+ if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "titogo", "fleet/pi", false, "", "", ""); 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", "", false, ""); err != nil {
+ if _, err := s.createApp(context.Background(), apiAcct{Seg: "bae"}, "unplaced", "", false, "", "", ""); err != nil {
t.Fatalf("createApp with no place: %v", err)
}
if sawPlaceKey && gotPlace != "" {
diff --git a/apps.go b/apps.go
index 8edeb97d6..c4c4e6540 100644
--- a/apps.go
+++ b/apps.go
@@ -451,6 +451,7 @@ func runAppsCreate(args []string) {
"carlos deploy polls it instead of the X-Carlos-Version header, which the edge "+
"stamps from the route row and which therefore lies until the process restarts; "+
`the build must appear as a whole whitespace-delimited word in the body, e.g. "ok abc1234"`)
+ region := fs.String("region", "", flagRegionAppsCreate)
// 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.
@@ -461,7 +462,7 @@ func runAppsCreate(args []string) {
fs.Parse(args)
if strings.TrimSpace(*app) == "" {
log.Fatal("usage: carlos apps create --app <name> [--place fleet/<f>] [--control-plane] " +
- "[--build-probe /path] [--account <sqid|name>] [--account-id <sqid>] [--console <origin>]")
+ "[--build-probe /path] [--region <code>] [--account <sqid|name>] [--account-id <sqid>] [--console <origin>]")
}
// Client-side gates before the server's: the create limiter charges
// ATTEMPTS, so a name — or a place or build-probe value — the server is
@@ -494,7 +495,8 @@ func runAppsCreate(args []string) {
}
printUsing(stdout, sess)
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
- res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2, *controlPlane, probe2)
+ res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2, *controlPlane, probe2,
+ strings.TrimSpace(*region), detectTimezone())
if err != nil {
log.Fatalf("apps create: %v", err)
}
@@ -502,6 +504,7 @@ func runAppsCreate(args []string) {
fmt.Fprintf(stdout, "%s %s in account %s — it's yours\n", c.ok("created app"), res.App, res.Account)
fmt.Fprintf(stdout, " open: %s\n", c.link(res.URL))
fmt.Fprintf(stdout, " ship: carlos ship --account %s --app %s ...\n", res.Account, res.App)
+ printAppRegion(stdout, res, strings.TrimSpace(*region))
// The app WAS created (everything above already printed and is true) —
// what's still unverified is whether --place actually stuck. The server
// echoes back what it really stored (handleAPICreateApp's "placement"
diff --git a/apps_region.go b/apps_region.go
new file mode 100644
index 000000000..a7c1d77a0
--- /dev/null
+++ b/apps_region.go
@@ -0,0 +1,29 @@
+package main
+
+import (
+ "fmt"
+ "io"
+)
+
+// printAppRegion is spec §11's one line after "created app …": nothing on
+// a single-region deployment, C13a when an older console ignored --region,
+// C13b when a console with the switch off did, else C9 to C12 by source.
+//
+// An empty region_source with a region present means the console could not
+// read the stored region back (it then answers the home with no source). That
+// says nothing about whether --region was applied, so it prints nothing: not
+// C13b, and not a C9 to C12 line that would name a source nobody knows.
+func printAppRegion(w io.Writer, res apiCreateAppResult, asked string) {
+ switch {
+ case res.Region == "" && res.RegionSource == "":
+ if asked != "" {
+ fmt.Fprintln(w, regionNotRecordedOldConsole)
+ }
+ case res.RegionSource == "":
+ // Region read-back failed on the console. Say nothing.
+ case asked != "" && res.Region != asked:
+ fmt.Fprintln(w, regionNotAppliedSwitchOff(res.RegionName))
+ case res.RegionsAvailable >= 2:
+ fmt.Fprintln(w, regionCreatedLine(res.RegionName, res.RegionSource))
+ }
+}
diff --git a/apps_region_test.go b/apps_region_test.go
new file mode 100644
index 000000000..f8e31ce62
--- /dev/null
+++ b/apps_region_test.go
@@ -0,0 +1,178 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestCreateAppSendsRegionAndTimezone(t *testing.T) {
+ var got map[string]any
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewDecoder(r.Body).Decode(&got)
+ json.NewEncoder(w).Encode(map[string]any{"account": "a", "app": "blog",
+ "region": "uk", "region_name": "UK · London", "region_source": "timezone", "regions_available": 3})
+ }))
+ defer ts.Close()
+ res, err := newTestSession(ts.URL).createApp(context.Background(), apiAcct{Seg: "a"}, "blog", "", false, "", "", "Europe/London")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got["timezone"] != "Europe/London" {
+ t.Fatalf("body = %v", got)
+ }
+ if _, sent := got["region"]; sent {
+ t.Fatal("region sent without --region")
+ }
+ if res.RegionName != "UK · London" || res.RegionsAvailable != 3 || res.Region != "uk" || res.RegionSource != "timezone" {
+ t.Fatalf("result = %+v", res)
+ }
+}
+
+// An explicit region travels in the body; an empty timezone is left out, so
+// a machine with no readable zone sends nothing.
+func TestCreateAppSendsExplicitRegionAndOmitsEmptyTimezone(t *testing.T) {
+ var got map[string]any
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewDecoder(r.Body).Decode(&got)
+ json.NewEncoder(w).Encode(map[string]any{"account": "a", "app": "blog"})
+ }))
+ defer ts.Close()
+ if _, err := newTestSession(ts.URL).createApp(context.Background(), apiAcct{Seg: "a"}, "blog", "", false, "", "us-east", ""); err != nil {
+ t.Fatal(err)
+ }
+ if got["region"] != "us-east" {
+ t.Fatalf("body = %v", got)
+ }
+ if _, sent := got["timezone"]; sent {
+ t.Fatalf("empty timezone sent: %v", got)
+ }
+}
+
+// A console that predates the region fields answers as it always did; the
+// new fields read as zero values.
+func TestCreateAppOldConsoleResponse(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"account": "a", "app": "blog", "url": "https://x/a/a/apps/blog"})
+ }))
+ defer ts.Close()
+ res, err := newTestSession(ts.URL).createApp(context.Background(), apiAcct{Seg: "a"}, "blog", "", false, "", "", "Europe/London")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.App != "blog" || res.Region != "" || res.RegionName != "" || res.RegionSource != "" || res.RegionsAvailable != 0 {
+ t.Fatalf("result = %+v", res)
+ }
+}
+
+func TestPrintAppRegion(t *testing.T) {
+ cases := []struct {
+ name string
+ res apiCreateAppResult
+ asked string
+ want string
+ }{
+ {"timezone", apiCreateAppResult{Region: "uk", RegionName: "UK · London", RegionSource: "timezone", RegionsAvailable: 2}, "", regionCreatedLine("UK · London", "timezone") + "\n"},
+ {"single region", apiCreateAppResult{Region: "ie", RegionName: "Ireland · Dublin", RegionSource: "default", RegionsAvailable: 1}, "", ""},
+ {"old console asked", apiCreateAppResult{}, "uk", regionNotRecordedOldConsole + "\n"},
+ {"switch off asked", apiCreateAppResult{Region: "ie", RegionName: "Ireland · Dublin", RegionSource: "default", RegionsAvailable: 2}, "uk", regionNotAppliedSwitchOff("Ireland · Dublin") + "\n"},
+ {"old console not asked", apiCreateAppResult{}, "", ""},
+ }
+ for _, c := range cases {
+ var b bytes.Buffer
+ printAppRegion(&b, c.res, c.asked)
+ if b.String() != c.want {
+ t.Errorf("%s: %q, want %q", c.name, b.String(), c.want)
+ }
+ }
+}
+
+// The full printed line for each source, with a place name and a region code
+// that differ, so a swapped argument shows.
+func TestPrintAppRegionFullLines(t *testing.T) {
+ cases := []struct {
+ source, asked, want string
+ }{
+ {"timezone", "", "Region: UK · London (from your timezone). Use --region to choose another.\n"},
+ {"ip", "", "Region: UK · London (from your network location). Use --region to choose another.\n"},
+ {"default", "", "Region: UK · London (the platform default). Use --region to choose another.\n"},
+ {"chosen", "uk", "Region: UK · London.\n"},
+ }
+ for _, c := range cases {
+ var b bytes.Buffer
+ printAppRegion(&b, apiCreateAppResult{Region: "uk", RegionName: "UK · London", RegionSource: c.source, RegionsAvailable: 3}, c.asked)
+ if b.String() != c.want {
+ t.Errorf("source %q: %q, want %q", c.source, b.String(), c.want)
+ }
+ }
+}
+
+// Controller ruling, switch off: the console ignores --region, and answers
+// the home with source "default". The CLI says so (C13b), in full.
+func TestPrintAppRegionSwitchOffFullLine(t *testing.T) {
+ var b bytes.Buffer
+ printAppRegion(&b, apiCreateAppResult{Region: "ie", RegionName: "Ireland · Dublin", RegionSource: "default", RegionsAvailable: 1}, "uk")
+ want := "This console is not assigning app regions yet, so --region was not applied. This app's instances start in Ireland · Dublin.\n"
+ if b.String() != want {
+ t.Fatalf("got %q, want %q", b.String(), want)
+ }
+}
+
+// Controller ruling, read-back failure: the console answers region = the home
+// and region_source = "". That is not "the console did not apply --region",
+// so the CLI prints neither C13a nor C13b, and no C9 to C12 line that would
+// claim a source it does not know.
+func TestPrintAppRegionReadBackFailureIsSilent(t *testing.T) {
+ for _, asked := range []string{"", "uk", "ie"} {
+ for _, avail := range []int{0, 1, 3} {
+ var b bytes.Buffer
+ printAppRegion(&b, apiCreateAppResult{Region: "ie", RegionName: "Ireland · Dublin", RegionSource: "", RegionsAvailable: avail}, asked)
+ if b.String() != "" {
+ t.Errorf("asked %q available %d: printed %q, want nothing", asked, avail, b.String())
+ }
+ }
+ }
+}
+
+// deploy's onboarding prints the region line after "created app", sends the
+// asked region, and never prints the timezone it sends.
+func TestOfferCreateAppPrintsRegionLine(t *testing.T) {
+ useTempCredentials(t)
+ out := captureStdout(t)
+ forceTerminal(t, true)
+ t.Setenv("TZ", "Europe/Secretville")
+ var got map[string]any
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewDecoder(r.Body).Decode(&got)
+ json.NewEncoder(w).Encode(map[string]any{"app": "taskmonger", "account": "bdk",
+ "url": "https://x/a/bdk/taskmonger", "region": "uk", "region_name": "UK · London",
+ "region_source": "chosen", "regions_available": 3})
+ }))
+ t.Cleanup(ts.Close)
+ dir := filepath.Join(t.TempDir(), "taskmonger")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Chdir(dir)
+ swapChooserIn(t, "y\n")
+ app := offerCreateApp(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bdk", Exact: true},
+ &noAppsError{Account: "tom@keymail.dev"}, "uk")
+ if app != "taskmonger" {
+ t.Fatalf("app = %q", app)
+ }
+ if got["region"] != "uk" || got["timezone"] != "Europe/Secretville" {
+ t.Fatalf("body = %v", got)
+ }
+ if !strings.Contains(out.String(), "created app") || !strings.Contains(out.String(), "Region: UK · London.\n") {
+ t.Fatalf("stdout = %q", out.String())
+ }
+ if strings.Contains(out.String(), "Secretville") {
+ t.Fatalf("the timezone was printed: %q", out.String())
+ }
+}
diff --git a/apps_test.go b/apps_test.go
index fd66fa59b..2042af874 100644
--- a/apps_test.go
+++ b/apps_test.go
@@ -308,7 +308,7 @@ func TestAPISessionCreateAppSendsAndEchoesControlPlaneAndProbe(t *testing.T) {
defer ts.Close()
s := newTestSession(ts.URL)
- res, err := s.createApp(context.Background(), apiAcct{Seg: "acct"}, "app", "", true, "/healthz")
+ res, err := s.createApp(context.Background(), apiAcct{Seg: "acct"}, "app", "", true, "/healthz", "", "")
if err != nil {
t.Fatalf("createApp: %v", err)
}
diff --git a/deploy.go b/deploy.go
index 38874a9ce..91dc3f5f6 100644
--- a/deploy.go
+++ b/deploy.go
@@ -56,11 +56,12 @@ func runDeploy(args []string) {
// both.
label := fs.String("label", "", "one line describing this release, for carlos releases and the console")
notes := fs.String("notes", "", "longer release notes")
+ regionFlag := fs.String("region", "", flagRegionDeploy)
fs.Usage = flagUsage(fs, "")
fs.Parse(args)
usage := "usage: carlos deploy --app <a> [--version <sha>] [--kind binary|static] [--target linux-arm64] " +
"[--account <sqid-or-name>] [--account-id <sqid>] [--console <origin> | -d <destination>] [--capabilities <file.json>] " +
- "[--channel <ch>] [--host <host>] [--label <text>] [--notes <text>] [--timeout 120s] <files-or-site-dir>\n" +
+ "[--channel <ch>] [--host <host>] [--region <code>] [--label <text>] [--notes <text>] [--timeout 120s] <files-or-site-dir>\n" +
"or, in a project with saved defaults (or at a terminal, to create them): carlos deploy [-d <destination>]"
if *ver == "" {
*ver = gitShortHead()
@@ -110,7 +111,7 @@ func runDeploy(args []string) {
// finish the onboarding — offer the claim, named after this
// directory, through the same door as `carlos apps create`
// (rate limiter and server validation unchanged).
- *app = offerCreateApp(ctx, sess, acct, na)
+ *app = offerCreateApp(ctx, sess, acct, na, strings.TrimSpace(*regionFlag))
newly["app"] = *app
case aerr != nil:
log.Fatal(aerr)
@@ -510,7 +511,7 @@ func deployArtifactWizard() (artifact, kind, target string, err error) {
// name — through the createApp door, or die with the refusal the other
// verbs print. Declining is the refusal too: deploy cannot proceed without
// an app, and inventing one silently is not on the table.
-func offerCreateApp(ctx context.Context, sess *apiSession, acct apiAcct, na *noAppsError) string {
+func offerCreateApp(ctx context.Context, sess *apiSession, acct apiAcct, na *noAppsError, region string) string {
name := ""
if cwd, err := os.Getwd(); err == nil {
base := filepath.Base(cwd)
@@ -533,11 +534,12 @@ func offerCreateApp(ctx context.Context, sess *apiSession, acct apiAcct, na *noA
log.Fatalf("deploy: %v", verr)
}
}
- res, err := sess.createApp(ctx, acct, name, "", false, "")
+ res, err := sess.createApp(ctx, acct, name, "", false, "", region, detectTimezone())
if err != nil {
log.Fatalf("deploy: create app: %v", err)
}
fmt.Fprintf(stdout, "%s %s in account %s\n", colorFor(stdout).ok("created app"), res.App, res.Account)
+ printAppRegion(stdout, res, region)
return res.App
}
diff --git a/deploy_wizard_test.go b/deploy_wizard_test.go
index 1287dca19..e1dbf1728 100644
--- a/deploy_wizard_test.go
+++ b/deploy_wizard_test.go
@@ -102,7 +102,7 @@ func TestOfferCreateAppUsesDirectoryName(t *testing.T) {
swapChooserIn(t, "y\n")
s := newTestSession(ts.URL)
app := offerCreateApp(context.Background(), s, apiAcct{Seg: "bdk", Exact: true},
- &noAppsError{Account: "tom@keymail.dev"})
+ &noAppsError{Account: "tom@keymail.dev"}, "")
if app != "taskmonger" || gotName != "taskmonger" {
t.Fatalf("created %q (server saw %q), want the directory's name", app, gotName)
}