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>
5 files changed,
+340
−50
account_choose.go+36 −0apimode.go+140 −0apimode_test.go+96 −0config.go+66 −28update.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 |