carlos / platform Public

cli: resolvedApp seam — omitted -app resolves like -account

One memoized memberships fetch answers both flags; config app key with
the console-pairing rule; one app auto-resolves, several get the chooser
plus a combined save offer, zero is a typed refusal deploy will turn
into the create offer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 1f5ddbb7f7c85ee1442bf9f59d039ca2ab723744 parent 306052f
5 files changed, +340 −50
  • account_choose.go +36 −0
  • apimode.go +140 −0
  • apimode_test.go +96 −0
  • config.go +66 −28
  • update.go +2 −22
diff --git a/account_choose.go b/account_choose.go
index 185083a39..e62b3ba46 100644
--- a/account_choose.go
+++ b/account_choose.go
@@ -22,6 +22,9 @@ type apiAccountEntry struct {
Name string `json:"name"`
Sqid string `json:"sqid"`
Role string `json:"role"`
+ // Apps rides the same response (handleAPIApps) — resolvedApp reads it,
+ // so account AND app resolution share one fetch per invocation.
+ Apps []string `json:"apps"`
}
// severalAccountsErr refuses an ambiguous omitted -account, naming every
@@ -80,6 +83,39 @@ func promptYes(out io.Writer, prompt string) bool {
return strings.EqualFold(promptLine(out, prompt), "y")
}
+// chooseOne asks which of items an omitted flag means: intro, a numbered
+// list, `<noun> [1-N]:`. A junk answer re-prompts once; two misses or EOF
+// return refuse — the same non-interactive refusal the caller would have
+// used without a terminal, so both paths error identically.
+func chooseOne(in io.Reader, out io.Writer, intro, noun string, items []string, refuse error) (string, error) {
+ fmt.Fprintln(out, intro)
+ for i, it := range items {
+ fmt.Fprintf(out, " %d. %s\n", i+1, it)
+ }
+ sc := bufio.NewScanner(in)
+ if in == chooserIn {
+ sc = chooserScanner()
+ }
+ for attempt := 0; attempt < 2; attempt++ {
+ fmt.Fprintf(out, "%s [1-%d]: ", noun, len(items))
+ if !sc.Scan() {
+ return "", refuse
+ }
+ if n, err := strconv.Atoi(strings.TrimSpace(sc.Text())); err == nil && n >= 1 && n <= len(items) {
+ return items[n-1], nil
+ }
+ }
+ return "", refuse
+}
+
+// severalAppsErr refuses an ambiguous omitted -app, naming every app so
+// the refusal itself answers "what could I have said" — severalAccountsErr
+// for the sibling flag.
+func severalAppsErr(account string, apps []string) error {
+ return fmt.Errorf("account %s has several apps — say which with -app: %s",
+ account, strings.Join(apps, ", "))
+}
+
// chooseAccount asks which account an omitted -account means, then — when
// saveDest names a file — whether to remember the answer there (the caller
// writes the config; this function only asks). An empty saveDest skips the
diff --git a/apimode.go b/apimode.go
index a5ede5b1b..40ac2cce4 100644
--- a/apimode.go
+++ b/apimode.go
@@ -50,6 +50,15 @@ type apiSession struct {
// once for the seal key, once for the write; two chooser prompts there
// once sealed a value with one account's key and wrote it to another).
defAcct *apiAcct
+ // The memberships response, fetched at most once (listAccounts):
+ // account resolution reads the accounts, app resolution reads each
+ // account's apps — same wire bytes, one request.
+ accts []apiAccountEntry
+ acctsSupported bool
+ acctsFetched bool
+ // defApp memoizes defaultApp's answer, defAcct's rule for the sibling
+ // flag: one resolution — and at most one chooser — per invocation.
+ defApp string
}
// resolveMode decides which mode the CLI is in. (nil, nil) means direct mode
@@ -383,11 +392,17 @@ func apiPath(a apiAcct, app, tail string) string {
// a real answer (401 expired token, 500) and surfaces via apiErrorFromBody
// — never swallowed into a fallback.
func (s *apiSession) listAccounts(ctx context.Context) (accts []apiAccountEntry, supported bool, err error) {
+ // Memoized: account resolution and app resolution both read this, and
+ // one invocation makes at most one fetch, however many of them run.
+ if s.acctsFetched {
+ return s.accts, s.acctsSupported, nil
+ }
status, body, err := s.getJSON(ctx, "/api/cli/apps")
if err != nil {
return nil, false, err
}
if status == http.StatusNotFound {
+ s.accts, s.acctsSupported, s.acctsFetched = nil, false, true
return nil, false, nil
}
if status != http.StatusOK {
@@ -399,6 +414,7 @@ func (s *apiSession) listAccounts(ctx context.Context) (accts []apiAccountEntry,
if err := json.Unmarshal(body, &out); err != nil {
return nil, false, fmt.Errorf("unreadable accounts response from %s: %w", s.console, err)
}
+ s.accts, s.acctsSupported, s.acctsFetched = out.Accounts, true, true
return out.Accounts, true, nil
}
@@ -490,6 +506,130 @@ func (s *apiSession) defaultAccountOnce(ctx context.Context) (apiAcct, error) {
return apiAcct{Seg: picked.Sqid, Exact: true}, nil
}
+// noAppsError is defaultApp's "this account owns nothing yet" refusal —
+// typed so zero-arg deploy can turn it into the create-an-app offer while
+// every other verb just prints it.
+type noAppsError struct{ Account string }
+
+func (e *noAppsError) Error() string {
+ return fmt.Sprintf("account %s has no apps — create one with: carlos apps create -app <name>", e.Account)
+}
+
+// defaultApp answers an omitted -app (2026-08-13 project-defaults spec):
+// the config default when its file's console pairs with this session,
+// else the resolved account's one app, else the chooser (terminal) or the
+// naming refusal (script). "" with a nil error means this console cannot
+// answer (pre-listing console, or an account the memberships response
+// does not name) — the caller falls back to its own usage error, exactly
+// the pre-spec behavior. offerSave=false lets zero-arg deploy fold the
+// answer into its one combined save instead of prompting twice.
+func (s *apiSession) defaultApp(ctx context.Context, acct apiAcct, offerSave bool) (string, error) {
+ if s.defApp != "" {
+ return s.defApp, nil
+ }
+ name, src, fileConsole, err := configApp()
+ if err != nil {
+ return "", err
+ }
+ if name != "" && fileConsole == s.console {
+ mutedf(stdout, "app: %s (the default from %s)", name, src)
+ s.defApp = name
+ return name, nil
+ }
+ accts, supported, err := s.listAccounts(ctx)
+ if err != nil {
+ return "", err
+ }
+ if !supported {
+ return "", nil
+ }
+ var entry *apiAccountEntry
+ for i := range accts {
+ if accts[i].Sqid == acct.Seg || accts[i].Name == acct.Seg {
+ entry = &accts[i]
+ break
+ }
+ }
+ if entry == nil {
+ // An explicit -account this token's memberships don't name: the
+ // verb's own request will answer that (the membership 404) — app
+ // resolution has nothing to add.
+ return "", nil
+ }
+ switch len(entry.Apps) {
+ case 0:
+ return "", &noAppsError{Account: entry.Name}
+ case 1:
+ mutedf(stdout, "app: %s", entry.Apps[0])
+ s.defApp = entry.Apps[0]
+ return entry.Apps[0], nil
+ }
+ if !interactiveTerminal() {
+ return "", severalAppsErr(entry.Name, entry.Apps)
+ }
+ picked, err := chooseOne(chooserIn, stdout,
+ fmt.Sprintf("account %s has several apps:", entry.Name), "app",
+ entry.Apps, severalAppsErr(entry.Name, entry.Apps))
+ if err != nil {
+ return "", err
+ }
+ s.defApp = picked
+ if offerSave {
+ offerSaveProjectDefaults(s.console, entry.Name, map[string]any{"app": picked},
+ fmt.Sprintf("save %s as this project's default app", picked))
+ }
+ return picked, nil
+}
+
+// offerSaveProjectDefaults is the one save prompt every resolution path
+// shares: console and account always land with whatever extra keys the
+// caller resolved (the file is read as pairs, and a default without its
+// console would silently stop applying). Skipped from $HOME (the
+// "project" file would be the global config) and never fatal — the
+// answers are already in hand, and bookkeeping must not kill the verb.
+func offerSaveProjectDefaults(console, account string, extra map[string]any, question string) {
+ dest := filepath.Join(".carlos", "config")
+ if abs, err := filepath.Abs(dest); err == nil {
+ if gp, gerr := globalConfigPath(); gerr == nil && abs == gp {
+ return
+ }
+ }
+ if !promptYes(stdout, question+" ("+dest+")? [y/N] ") {
+ return
+ }
+ set := map[string]any{"console": console}
+ if account != "" {
+ set["account"] = account
+ }
+ for k, v := range extra {
+ set[k] = v
+ }
+ if err := writeConfigKeys(dest, set, 0o755); err != nil {
+ warnf(stdout, "not saved (%s): %v", dest, err)
+ } else {
+ mutedf(stdout, "saved: project defaults in %s", dest)
+ }
+}
+
+// resolvedApp is defaultApp's verb-facing wrapper, resolvedAccount's rule
+// for the sibling flag: explicit -app travels verbatim; an empty one
+// resolves or dies on the verb's own usage message when this console
+// cannot answer. Fatal for the same reason resolvedAccount is — every
+// caller's next line is the request itself.
+func resolvedApp(sess *apiSession, acct apiAcct, app, usage string) string {
+ if app != "" {
+ return app
+ }
+ r, err := sess.defaultApp(context.Background(), acct, true)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if r == "" {
+ log.Fatal(usage)
+ }
+ return r
+}
+
// resolvedAccount is apiAccount plus the omitted-flag default: the seam
// every API-mode verb reaches through. Explicit flags travel verbatim and
// never touch the network; only an empty pair asks defaultAccount. Fatal
diff --git a/apimode_test.go b/apimode_test.go
index b8b1c85aa..e0bfd941a 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -8,6 +8,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"flag"
"io"
"net/http"
@@ -3486,6 +3487,101 @@ func TestResolveModeUnconfiguredPromptsForURL(t *testing.T) {
}
}
+func TestDefaultAppSingleSharesTheFetch(t *testing.T) {
+ useTempCredentials(t)
+ out := captureStdout(t)
+ s, hits := accountsConsole(t, http.StatusOK,
+ `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["taskmonger"]}]}`)
+
+ // Account then app: one invocation, ONE fetch answering both.
+ acct, err := s.defaultAccount(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ app, err := s.defaultApp(context.Background(), acct, false)
+ if err != nil || app != "taskmonger" {
+ t.Fatalf("one app = %q, %v", app, err)
+ }
+ if *hits != 1 {
+ t.Fatalf("endpoint hits = %d, want 1 shared fetch", *hits)
+ }
+ if !strings.Contains(out.String(), "app: taskmonger") {
+ t.Fatalf("the resolved app must be printed:\n%s", out.String())
+ }
+}
+
+func TestDefaultAppSeveralNonTTY(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ forceTerminal(t, false)
+ s, _ := accountsConsole(t, http.StatusOK,
+ `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["shop","blog"]}]}`)
+
+ _, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false)
+ want := "account tom@keymail.dev has several apps — say which with -app: shop, blog"
+ if err == nil || err.Error() != want {
+ t.Fatalf("several = %v\nwant %q", err, want)
+ }
+}
+
+func TestDefaultAppChooserSaves(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ forceTerminal(t, true)
+ oldIn := chooserIn
+ chooserIn = strings.NewReader("2\ny\n")
+ t.Cleanup(func() { chooserIn = oldIn })
+ s, _ := accountsConsole(t, http.StatusOK,
+ `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["shop","blog"]}]}`)
+
+ app, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, true)
+ if err != nil || app != "blog" {
+ t.Fatalf("chooser pick = %q, %v", app, err)
+ }
+ cfg, err := loadConfig(filepath.Join(".carlos", "config"))
+ if err != nil || cfg.App != "blog" || cfg.Console != s.console || cfg.Account != "tom@keymail.dev" {
+ t.Fatalf("saved config = %+v, %v; want console+account+app together", cfg, err)
+ }
+}
+
+func TestDefaultAppZeroIsTyped(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ s, _ := accountsConsole(t, http.StatusOK,
+ `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":[]}]}`)
+
+ _, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false)
+ var na *noAppsError
+ if err == nil || !errors.As(err, &na) || na.Account != "tom@keymail.dev" ||
+ !strings.Contains(err.Error(), "carlos apps create") {
+ t.Fatalf("zero apps = %v, want the typed no-apps refusal naming the fix", err)
+ }
+}
+
+func TestDefaultAppConfigPairing(t *testing.T) {
+ useTempCredentials(t)
+ captureStdout(t)
+ s, hits := accountsConsole(t, http.StatusOK,
+ `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","apps":["shop","blog"]}]}`)
+
+ // Paired with THIS console: honored without any fetch.
+ writeConfig(t, ".", `{"console": "`+s.console+`", "app": "shop"}`)
+ app, err := s.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false)
+ if err != nil || app != "shop" || *hits != 0 {
+ t.Fatalf("config app = %q, %v, hits=%d", app, err, *hits)
+ }
+
+ // Paired with ANOTHER console: ignored — falls through to the account's
+ // apps, which are ambiguous here, proving the fall-through.
+ writeConfig(t, ".", `{"console": "https://other.example", "app": "shop"}`)
+ forceTerminal(t, false)
+ s2 := newTestSession(s.console)
+ if _, err := s2.defaultApp(context.Background(), apiAcct{Seg: "bdk", Exact: true}, false); err == nil ||
+ !strings.Contains(err.Error(), "several apps") {
+ t.Fatalf("mismatched config console = %v, want fall-through", err)
+ }
+}
+
func TestResolvedAccountExplicitFlagsBypass(t *testing.T) {
useTempCredentials(t)
captureStdout(t)
diff --git a/config.go b/config.go
index 4ea31d193..15814356a 100644
--- a/config.go
+++ b/config.go
@@ -42,11 +42,74 @@ const defaultConsole = "https://platform.carlosframework.com"
type cliConfig struct {
Console string `json:"console"`
Account string `json:"account"`
+ // The project-defaults block (2026-08-13 spec): what this checkout
+ // deploys as. App is honored by every app-scoped verb; Kind/Artifact/
+ // Target answer zero-arg `carlos deploy`. All console-paired like
+ // Account: a file's defaults mean THIS stack.
+ App string `json:"app"`
+ Kind string `json:"kind"`
+ Artifact string `json:"artifact"`
+ Target string `json:"target"`
// UpdateCheck: "off" disables the once-a-day newer-release hint
// (update.go). Any other value — including absent — leaves it on.
UpdateCheck string `json:"update_check"`
}
+// walkConfig finds the nearest .carlos/config (cwd upward, then global)
+// that pick() accepts, returning it with its path. The zero config and ""
+// mean "no file said anything". Every per-key default walk (account, app,
+// update_check) is this walk with a different pick.
+func walkConfig(pick func(cliConfig) bool) (cliConfig, string, error) {
+ if dir, werr := os.Getwd(); werr == nil {
+ for {
+ path := filepath.Join(dir, ".carlos", "config")
+ cfg, err := loadConfig(path)
+ if err != nil {
+ return cliConfig{}, "", err
+ }
+ if pick(cfg) {
+ return cfg, path, nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ break
+ }
+ dir = parent
+ }
+ }
+ path, perr := globalConfigPath()
+ if perr != nil {
+ return cliConfig{}, "", nil
+ }
+ cfg, err := loadConfig(path)
+ if err != nil {
+ return cliConfig{}, "", err
+ }
+ if !pick(cfg) {
+ return cliConfig{}, "", nil
+ }
+ return cfg, path, nil
+}
+
+// configApp resolves the configured default APP: the nearest file whose
+// app key is set, with that same file's console for the pairing rule —
+// exactly configAccount's contract, for the sibling key.
+func configApp() (app, source, fileConsole string, err error) {
+ cfg, src, err := walkConfig(func(c cliConfig) bool { return c.App != "" })
+ if err != nil || src == "" {
+ return "", "", "", err
+ }
+ return cfg.App, src, cfg.Console, nil
+}
+
+// configDeploy resolves the project's deploy block: the nearest file that
+// names an artifact, whole — kind/artifact/target travel together, and
+// mixing halves of two files would ship one project's bytes with
+// another's shape.
+func configDeploy() (cfg cliConfig, source string, err error) {
+ return walkConfig(func(c cliConfig) bool { return c.Artifact != "" })
+}
+
// loadConfig reads one .carlos/config whole. A missing file is a zero
// config, not an error. A file that cannot be parsed, or names a console
// normalizeConsole refuses, is an ERROR — silently ignoring a typo'd
@@ -139,36 +202,11 @@ func configDefault() (console, source string, err error) {
// stack, and -console to a different stack must not drag the other stack's
// account along.
func configAccount() (account, source, fileConsole string, err error) {
- if dir, werr := os.Getwd(); werr == nil {
- for {
- path := filepath.Join(dir, ".carlos", "config")
- cfg, err := loadConfig(path)
- if err != nil {
- return "", "", "", err
- }
- if cfg.Account != "" {
- return cfg.Account, path, cfg.Console, nil
- }
- parent := filepath.Dir(dir)
- if parent == dir {
- break
- }
- dir = parent
- }
- }
- path, perr := globalConfigPath()
- if perr != nil {
- // No home directory means no global config — a state, not an error.
- return "", "", "", nil
- }
- cfg, err := loadConfig(path)
- if err != nil {
+ cfg, src, err := walkConfig(func(c cliConfig) bool { return c.Account != "" })
+ if err != nil || src == "" {
return "", "", "", err
}
- if cfg.Account == "" {
- return "", "", "", nil
- }
- return cfg.Account, path, cfg.Console, nil
+ return cfg.Account, src, cfg.Console, nil
}
// globalConfigPath is ~/.carlos/config — the same directory that holds
diff --git a/update.go b/update.go
index 357b8e16f..31b1d198c 100644
--- a/update.go
+++ b/update.go
@@ -172,28 +172,8 @@ func maybeUpdateHint(cmd string) {
// nearest .carlos/config with an update_check key, else the global. Only
// the literal "off" disables.
func configUpdateCheckOff() (bool, error) {
- if dir, werr := os.Getwd(); werr == nil {
- for {
- cfg, err := loadConfig(filepath.Join(dir, ".carlos", "config"))
- if err != nil {
- return false, err
- }
- if cfg.UpdateCheck != "" {
- return cfg.UpdateCheck == "off", nil
- }
- parent := filepath.Dir(dir)
- if parent == dir {
- break
- }
- dir = parent
- }
- }
- path, perr := globalConfigPath()
- if perr != nil {
- return false, nil
- }
- cfg, err := loadConfig(path)
- if err != nil {
+ cfg, src, err := walkConfig(func(c cliConfig) bool { return c.UpdateCheck != "" })
+ if err != nil || src == "" {
return false, err
}
return cfg.UpdateCheck == "off", nil