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>
8 files changed,
+238
−16
apimode.go+12 −2apimode_test.go+6 −6apps.go+5 −2apps_region.go+29 −0apps_region_test.go+178 −0apps_test.go+1 −1deploy.go+6 −4deploy_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) |
| } |