Merge pull request #159 from carlosframework/worktree-account-default
CLI: default -account to your own account; membership discovery (#157)
23 files changed,
+1401
−100
account_choose.go+86 −0account_choose_test.go+89 −0accounts.go+71 −3accounts_list_test.go+81 −0accounts_resolve_test.go+12 −0apimode.go+146 −0apimode_test.go+245 −0appconfig_cli.go+16 −10apps.go+8 −7auth.go+15 −0auth_test.go+58 −7cli.go+29 −19config.go+112 −23config_test.go+96 −0deploy.go+2 −2docs/superpowers/plans/2026-08-12-account-default-resolution.md+122 −0docs/superpowers/specs/2026-08-12-account-default-resolution-design.md+182 −0fleets.go+14 −13instances_cli.go+7 −7logs.go+2 −2restart.go+1 −1retention.go+1 −1store.go+6 −5
diff --git a/account_choose.go b/account_choose.go| new file mode 100644 |
| index 000000000..1f4594be4 |
| --- /dev/null |
| +++ b/account_choose.go |
| @@ -0,0 +1,86 @@ |
| +// The interactive account chooser: what an omitted -account means when the |
| +// caller belongs to several accounts and a human is at the terminal. The |
| +// non-interactive shape of the same question is severalAccountsErr — a |
| +// script cannot answer a prompt, and refusing loudly beats hanging; the |
| +// refusal doubles as membership discovery, since it names every account. |
| +package main |
| + |
| +import ( |
| + "bufio" |
| + "fmt" |
| + "io" |
| + "os" |
| + "strconv" |
| + "strings" |
| +) |
| + |
| +// apiAccountEntry is one row of GET /api/cli/apps's "accounts" list |
| +// (internal/console handleAPIApps) — only the fields this client reads. |
| +// The endpoint's order is the store's creation order, so entry 1 is the |
| +// personal account minted at sign-up. |
| +type apiAccountEntry struct { |
| + Name string `json:"name"` |
| + Sqid string `json:"sqid"` |
| + Role string `json:"role"` |
| +} |
| + |
| +// severalAccountsErr refuses an ambiguous omitted -account, naming every |
| +// membership so the refusal itself answers "what could I have said". |
| +func severalAccountsErr(accts []apiAccountEntry) error { |
| + names := make([]string, 0, len(accts)) |
| + for _, a := range accts { |
| + names = append(names, fmt.Sprintf("%s (%s)", a.Name, a.Sqid)) |
| + } |
| + return fmt.Errorf("you belong to several accounts — say which with -account: %s", |
| + strings.Join(names, ", ")) |
| +} |
| + |
| +// interactiveTerminal reports whether a prompt has a human on both ends. |
| +// Stdin AND stdout must be terminals: a redirected stdout means the output |
| +// is for a parser, and a redirected stdin has no one to answer. A var, not |
| +// a func: a global that reads real file descriptors is untestable in both |
| +// directions (under `go test` stdin is /dev/null — a char device — and only |
| +// the piped stdout saves the check), so tests set it explicitly. |
| +var interactiveTerminal = func() bool { |
| + return charDevice(os.Stdin) && charDevice(os.Stdout) |
| +} |
| + |
| +// chooserIn is where chooseAccount reads its answers — os.Stdin, swapped by |
| +// tests for the same reason interactiveTerminal is a var. |
| +var chooserIn io.Reader = os.Stdin |
| + |
| +// 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 |
| +// save prompt entirely: from $HOME the "project" file would be the global |
| +// config, and a y/N inside a ship must not carry that weight. A junk answer |
| +// re-prompts once; two misses, or EOF at the pick, fall back to the |
| +// non-interactive refusal so the caller's error path is identical either |
| +// way. EOF or anything but y/Y at the save prompt means no — the pick |
| +// itself stands. |
| +func chooseAccount(in io.Reader, out io.Writer, console string, accts []apiAccountEntry, saveDest string) (apiAccountEntry, bool, error) { |
| + fmt.Fprintf(out, "you belong to several accounts on %s:\n", console) |
| + for i, a := range accts { |
| + fmt.Fprintf(out, " %d. %s (%s)\n", i+1, a.Name, a.Sqid) |
| + } |
| + sc := bufio.NewScanner(in) |
| + var picked *apiAccountEntry |
| + for attempt := 0; attempt < 2 && picked == nil; attempt++ { |
| + fmt.Fprintf(out, "account [1-%d]: ", len(accts)) |
| + if !sc.Scan() { |
| + return apiAccountEntry{}, false, severalAccountsErr(accts) |
| + } |
| + if n, err := strconv.Atoi(strings.TrimSpace(sc.Text())); err == nil && n >= 1 && n <= len(accts) { |
| + picked = &accts[n-1] |
| + } |
| + } |
| + if picked == nil { |
| + return apiAccountEntry{}, false, severalAccountsErr(accts) |
| + } |
| + if saveDest == "" { |
| + return *picked, false, nil |
| + } |
| + fmt.Fprintf(out, "save %s as this directory's default account (%s)? [y/N] ", picked.Name, saveDest) |
| + save := sc.Scan() && strings.EqualFold(strings.TrimSpace(sc.Text()), "y") |
| + return *picked, save, nil |
| +} |
diff --git a/account_choose_test.go b/account_choose_test.go| new file mode 100644 |
| index 000000000..4e2d10ba6 |
| --- /dev/null |
| +++ b/account_choose_test.go |
| @@ -0,0 +1,89 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestSeveralAccountsErr(t *testing.T) { |
| + err := severalAccountsErr([]apiAccountEntry{ |
| + {Name: "tom@keymail.dev", Sqid: "bdk"}, {Name: "acme", Sqid: "bce"}}) |
| + want := "you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)" |
| + if err.Error() != want { |
| + t.Fatalf("severalAccountsErr = %q\nwant %q", err, want) |
| + } |
| +} |
| + |
| +func TestChooseAccount(t *testing.T) { |
| + accts := []apiAccountEntry{ |
| + {Name: "tom@keymail.dev", Sqid: "bdk"}, {Name: "acme", Sqid: "bce"}} |
| + refusal := severalAccountsErr(accts).Error() |
| + |
| + // A clean pick of the second entry, declining the save. |
| + var out bytes.Buffer |
| + picked, save, err := chooseAccount(strings.NewReader("2\nn\n"), &out, "https://console.example", accts, ".carlos/config") |
| + if err != nil || picked.Sqid != "bce" || save { |
| + t.Fatalf("pick 2 = %+v save=%v err=%v", picked, save, err) |
| + } |
| + for _, want := range []string{ |
| + "you belong to several accounts on https://console.example", |
| + "1. tom@keymail.dev (bdk)", "2. acme (bce)", "account [1-2]:", |
| + "save acme as this directory's default account (.carlos/config)? [y/N]", |
| + } { |
| + if !strings.Contains(out.String(), want) { |
| + t.Fatalf("chooser output missing %q:\n%s", want, out.String()) |
| + } |
| + } |
| + |
| + // Saying y to the save prompt. |
| + out.Reset() |
| + picked, save, err = chooseAccount(strings.NewReader("1\ny\n"), &out, "https://console.example", accts, ".carlos/config") |
| + if err != nil || picked.Sqid != "bdk" || !save { |
| + t.Fatalf("pick 1 + save = %+v save=%v err=%v", picked, save, err) |
| + } |
| + |
| + // An empty saveDest — the caller decided saving is off the table (from |
| + // $HOME the "project" file would be the global config) — asks nothing |
| + // and never saves, even with a y queued up. |
| + out.Reset() |
| + picked, save, err = chooseAccount(strings.NewReader("1\ny\n"), &out, "https://console.example", accts, "") |
| + if err != nil || picked.Sqid != "bdk" || save { |
| + t.Fatalf("no-dest pick = %+v save=%v err=%v, want pick without a save", picked, save, err) |
| + } |
| + if strings.Contains(out.String(), "save ") { |
| + t.Fatalf("no-dest chooser must not offer the save:\n%s", out.String()) |
| + } |
| + |
| + // An empty save answer means no — N is the default. |
| + out.Reset() |
| + _, save, err = chooseAccount(strings.NewReader("1\n\n"), &out, "https://console.example", accts, ".carlos/config") |
| + if err != nil || save { |
| + t.Fatalf("empty save answer = save=%v err=%v, want no-save", save, err) |
| + } |
| + |
| + // EOF at the save prompt also means no; the pick itself stands. |
| + out.Reset() |
| + picked, save, err = chooseAccount(strings.NewReader("1\n"), &out, "https://console.example", accts, ".carlos/config") |
| + if err != nil || picked.Sqid != "bdk" || save { |
| + t.Fatalf("EOF at save = %+v save=%v err=%v", picked, save, err) |
| + } |
| + |
| + // Junk once re-prompts; a valid second answer lands. |
| + out.Reset() |
| + picked, _, err = chooseAccount(strings.NewReader("zzz\n1\nn\n"), &out, "https://console.example", accts, ".carlos/config") |
| + if err != nil || picked.Sqid != "bdk" { |
| + t.Fatalf("junk-then-1 = %+v err=%v", picked, err) |
| + } |
| + if strings.Count(out.String(), "account [1-2]:") != 2 { |
| + t.Fatalf("want exactly one re-prompt:\n%s", out.String()) |
| + } |
| + |
| + // Junk twice — including out-of-range numbers — is the refusal. |
| + for _, input := range []string{"zzz\nzzz\n", "0\n3\n", ""} { |
| + out.Reset() |
| + if _, _, err = chooseAccount(strings.NewReader(input), &out, "https://console.example", accts, ".carlos/config"); err == nil || err.Error() != refusal { |
| + t.Fatalf("input %q = %v, want the several-accounts refusal", input, err) |
| + } |
| + } |
| +} |
diff --git a/accounts.go b/accounts.go| index b398cc583..d20bcac4f 100644 |
| --- a/accounts.go |
| +++ b/accounts.go |
| @@ -16,6 +16,7 @@ import ( |
| "errors" |
| "flag" |
| "fmt" |
| + "io" |
| "log" |
| "net/http" |
| "os" |
| @@ -39,7 +40,13 @@ const accountsUsage = `usage: carlos accounts <create|list|migrate> |
| over any logged-in one. |
| [-console <url>] which logged-in console to mint through |
| (only needed with several) |
| - list sqid, name, and app count |
| + list your accounts. Signed-in (the usual case): |
| + name, sqid and your role, from the console. |
| + On a box with a registry: sqid, name and |
| + app count from the registry itself. |
| + [-console <url>] list through a console even where a |
| + registry exists (or: which logged-in |
| + console, with several) |
| migrate -app <a> -to <sqid> copy an app's objects to the account- |
| qualified prefix and re-stamp its routes |
| [-from <sqid>] source account (default: the legacy, |
| @@ -203,9 +210,70 @@ func createAccountViaConsole(cred *credential, regPath, name string) { |
| func runAccountsList(args []string) { |
| fs := flag.NewFlagSet("accounts list", flag.ExitOnError) |
| - regPath := fs.String("registry", defaultRegistry(), "registry database") |
| + regPath := fs.String("registry", defaultRegistry(), "registry database (box mode)") |
| + consoleFlag := fs.String("console", "", "list through a console instead (default: the box registry when one exists, else your logged-in console)") |
| fs.Parse(args) |
| - r, err := registry.Open(*regPath) |
| + // Mode split by what exists, not by resolveMode: this verb predates the |
| + // console API and must keep working on a box that has a registry and no |
| + // bucket env, no credentials — while a laptop with neither registry nor |
| + // box access (the #157 case) asks its console. An explicit -console |
| + // skips the registry even where one exists; an explicit -registry is a |
| + // commitment, not a hint — a typo'd path or an EACCES must surface as |
| + // registry.Open's own error, never as a silent hop to console mode |
| + // answering "not logged in" to someone who asked about a database. |
| + explicitRegistry := false |
| + fs.Visit(func(f *flag.Flag) { |
| + if f.Name == "registry" { |
| + explicitRegistry = true |
| + } |
| + }) |
| + if *consoleFlag == "" { |
| + if explicitRegistry { |
| + runAccountsListRegistry(*regPath) |
| + return |
| + } |
| + if _, err := os.Stat(*regPath); err == nil { |
| + runAccountsListRegistry(*regPath) |
| + return |
| + } |
| + } |
| + if err := accountsListAPI(os.Stdout, *consoleFlag); err != nil { |
| + log.Fatal(err) |
| + } |
| +} |
| + |
| +// accountsListAPI is the signed-in form: the caller's own memberships from |
| +// GET /api/cli/apps, as a NAME/SQID/ROLE table. The one 404 this path can |
| +// see is a console too old to have the endpoint (no account/app segment |
| +// here for the membership gate to refuse), so it gets a named refusal |
| +// instead of notFoundErr's check-your-flags riddle. |
| +func accountsListAPI(out io.Writer, consoleFlag string) error { |
| + cred, err := loggedInCredential(consoleFlag) |
| + if err != nil { |
| + return err |
| + } |
| + var acc struct { |
| + Accounts []apiAccountEntry `json:"accounts"` |
| + } |
| + status, err := newConsoleClient(cred.Console, cred.Token). |
| + do(context.Background(), http.MethodGet, "/api/cli/apps", nil, &acc) |
| + if status == http.StatusNotFound { |
| + return fmt.Errorf("%s predates account listing — upgrade the console, or run this on a box with -registry", cred.Console) |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + w := newTable(out) |
| + fmt.Fprintln(w, "NAME\tSQID\tROLE") |
| + for _, a := range acc.Accounts { |
| + fmt.Fprintf(w, "%s\t%s\t%s\n", a.Name, a.Sqid, a.Role) |
| + } |
| + w.Flush() |
| + return nil |
| +} |
| + |
| +func runAccountsListRegistry(regPath string) { |
| + r, err := registry.Open(regPath) |
| if err != nil { |
| log.Fatal(err) |
| } |
diff --git a/accounts_list_test.go b/accounts_list_test.go| new file mode 100644 |
| index 000000000..3bb1a8610 |
| --- /dev/null |
| +++ b/accounts_list_test.go |
| @@ -0,0 +1,81 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// signedInConsole plants a credential for an httptest console that serves |
| +// GET /api/cli/apps, and returns that console's URL. |
| +func signedInConsole(t *testing.T, handler http.HandlerFunc) string { |
| + t.Helper() |
| + ts := httptest.NewServer(handler) |
| + t.Cleanup(ts.Close) |
| + f, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.put(credential{Console: ts.URL, Address: "tom@keymail.dev", Token: "tok", |
| + CreatedAt: time.Now(), ExpiresAt: time.Now().Add(time.Hour)}) |
| + if err := f.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + return ts.URL |
| +} |
| + |
| +// accounts list, signed-in: no registry on this machine, a credential and a |
| +// console that answers — the no-infra path #157 was filed for. |
| +func TestAccountsListSignedIn(t *testing.T) { |
| + useTempCredentials(t) |
| + signedInConsole(t, func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/cli/apps" || r.Header.Get("Authorization") != "Bearer tok" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{ |
| + {"sqid": "bdk", "name": "tom@keymail.dev", "role": "owner", "apps": []string{}}, |
| + {"sqid": "bce", "name": "acme", "role": "member", "apps": []string{"shop"}}, |
| + }}) |
| + }) |
| + |
| + var out bytes.Buffer |
| + if err := accountsListAPI(&out, ""); err != nil { |
| + t.Fatalf("accountsListAPI: %v", err) |
| + } |
| + for _, want := range []string{"NAME", "SQID", "ROLE", |
| + "tom@keymail.dev", "bdk", "owner", "acme", "bce", "member"} { |
| + if !strings.Contains(out.String(), want) { |
| + t.Fatalf("table missing %q:\n%s", want, out.String()) |
| + } |
| + } |
| +} |
| + |
| +// A console from before the listing endpoint gets a plain named refusal, |
| +// not the membership-404 riddle. |
| +func TestAccountsListOldConsole(t *testing.T) { |
| + useTempCredentials(t) |
| + url := signedInConsole(t, func(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) |
| + }) |
| + |
| + var out bytes.Buffer |
| + err := accountsListAPI(&out, "") |
| + if err == nil || !strings.Contains(err.Error(), url) || !strings.Contains(err.Error(), "predates") { |
| + t.Fatalf("old console = %v, want a refusal naming the console", err) |
| + } |
| +} |
| + |
| +// Not logged in and no registry: the error names the fix, exactly pick()'s. |
| +func TestAccountsListNeitherMode(t *testing.T) { |
| + useTempCredentials(t) |
| + var out bytes.Buffer |
| + if err := accountsListAPI(&out, ""); err == nil || |
| + !strings.Contains(err.Error(), "carlos auth login") { |
| + t.Fatalf("no credential = %v, want the login hint", err) |
| + } |
| +} |
diff --git a/accounts_resolve_test.go b/accounts_resolve_test.go| index 4464126ab..54f751cdf 100644 |
| --- a/accounts_resolve_test.go |
| +++ b/accounts_resolve_test.go |
| @@ -9,6 +9,18 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| +// The load-bearing invariant behind the account-default branch (#157): the |
| +// flag default flipped from "ops" to "" for every dual-mode verb, and |
| +// direct mode survives ONLY because resolveAccount reads "" as ops — with |
| +// no registry involved, since CI and fresh boxes have none. |
| +func TestResolveAccountEmptyMeansOps(t *testing.T) { |
| + for _, want := range []string{"", "ops", " ops ", " "} { |
| + if got := resolveAccount("/nonexistent/registry.db", want, ""); got != release.OpsAccount { |
| + t.Errorf("resolveAccount(%q) = %q, want %q", want, got, release.OpsAccount) |
| + } |
| + } |
| +} |
| + |
| func TestPickAccount(t *testing.T) { |
| accts := []registry.Account{ |
| {ID: 1, Sqid: "bab", Name: "ops"}, |
diff --git a/apimode.go b/apimode.go| index a26cd81bb..2eb5a1b91 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -22,6 +22,7 @@ import ( |
| "errors" |
| "fmt" |
| "io" |
| + "log" |
| "mime/multipart" |
| "net/http" |
| "net/url" |
| @@ -44,6 +45,11 @@ type apiSession struct { |
| c *consoleClient |
| console string // origin, for messages |
| address string |
| + // defAcct memoizes defaultAccount's answer: resolution happens once per |
| + // invocation however many times a verb asks (runSecretsSet asks twice — |
| + // 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 |
| } |
| // resolveMode decides which mode the CLI is in. (nil, nil) means direct mode |
| @@ -226,6 +232,146 @@ func apiPath(a apiAcct, app, tail string) string { |
| return p |
| } |
| +// listAccounts fetches the caller's own memberships: GET /api/cli/apps' |
| +// "accounts" array (handleAPIApps — live since main 5856dc5, 2026-08-03). |
| +// supported=false means the console predates the endpoint: the bare mux |
| +// 404, which cannot be the membership gate's 404 because this path carries |
| +// no account or app segment for a gate to refuse. Every other non-200 is |
| +// 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) { |
| + status, body, err := s.getJSON(ctx, "/api/cli/apps") |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + if status == http.StatusNotFound { |
| + return nil, false, nil |
| + } |
| + if status != http.StatusOK { |
| + return nil, false, apiErrorFromBody(status, body) |
| + } |
| + var out struct { |
| + Accounts []apiAccountEntry `json:"accounts"` |
| + } |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return nil, false, fmt.Errorf("unreadable accounts response from %s: %w", s.console, err) |
| + } |
| + return out.Accounts, true, nil |
| +} |
| + |
| +// defaultAccount answers an omitted -account in API mode (spec: |
| +// docs/superpowers/specs/2026-08-12-account-default-resolution-design.md): |
| +// the config default when its file's console pairs with this session, else |
| +// the caller's one membership, else the chooser (terminal) or the naming |
| +// refusal (script). Whatever is decided for the caller is printed — a |
| +// guessed account that stayed invisible would be the wrong-deployment |
| +// mistake this file already refuses everywhere else. |
| +func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error) { |
| + if s.defAcct != nil { |
| + return *s.defAcct, nil |
| + } |
| + a, err := s.defaultAccountOnce(ctx) |
| + if err == nil { |
| + s.defAcct = &a |
| + } |
| + return a, err |
| +} |
| + |
| +func (s *apiSession) defaultAccountOnce(ctx context.Context) (apiAcct, error) { |
| + name, src, fileConsole, err := configAccount() |
| + if err != nil { |
| + return apiAcct{}, err |
| + } |
| + // Honored ONLY when the file names THIS console: the pair is one fact. |
| + // A consoleless {"account": …} is deliberately not a wildcard — the |
| + // console key is nearly inert as committed input (no credential, no |
| + // request), but an account that applied to every console would let a |
| + // checked-in file silently redirect a ship between stacks the caller |
| + // really is a member of. saveConfigAccount always writes the pair, so |
| + // only a hand-written file can even be consoleless. |
| + if name != "" && fileConsole == s.console { |
| + // The name travels verbatim, like a typed -account: the console |
| + // resolves it against the caller's memberships, so a stale config |
| + // value fails with the same membership 404 a typo would. |
| + mutedf(stdout, "account: %s (the default from %s)", name, src) |
| + return apiAcct{Seg: name}, nil |
| + } |
| + accts, supported, err := s.listAccounts(ctx) |
| + if err != nil { |
| + return apiAcct{}, err |
| + } |
| + if !supported { |
| + // No memberships answer — a console from before the listing |
| + // endpoint, or any 404-shaped thing in front of one. Keep the |
| + // pre-discovery default so old consoles regress nothing, but SAY |
| + // so: a silent ops here followed by the membership 404 is exactly |
| + // the three-failures-deep riddle #157 was filed about. |
| + mutedf(stdout, "account: ops (%s did not answer with your memberships)", s.console) |
| + return apiAcct{Seg: "ops"}, nil |
| + } |
| + switch len(accts) { |
| + case 0: |
| + return apiAcct{}, fmt.Errorf("your address belongs to no account on %s — create one with: carlos accounts create -name <name>", s.console) |
| + case 1: |
| + mutedf(stdout, "account: %s (%s)", accts[0].Name, accts[0].Sqid) |
| + return apiAcct{Seg: accts[0].Sqid, Exact: true}, nil |
| + } |
| + if !interactiveTerminal() { |
| + return apiAcct{}, severalAccountsErr(accts) |
| + } |
| + // ./.carlos/config in the working directory — the same "run it where |
| + // you want the file" rule as `auth default -project`. Except from |
| + // $HOME, where that path IS ~/.carlos/config: a y there would silently |
| + // repoint the machine-wide default console from inside a ship, so the |
| + // save is not offered at all. |
| + dest := filepath.Join(".carlos", "config") |
| + if abs, err := filepath.Abs(dest); err == nil { |
| + if gp, gerr := globalConfigPath(); gerr == nil && abs == gp { |
| + dest = "" |
| + } |
| + } |
| + picked, save, err := chooseAccount(chooserIn, stdout, s.console, accts, dest) |
| + if err != nil { |
| + return apiAcct{}, err |
| + } |
| + if save && dest != "" { |
| + // The save is bookkeeping, never a precondition: the human already |
| + // answered, the account is known, and a read-only checkout must |
| + // not turn that answer into a dead ship. |
| + if err := saveConfigAccount(dest, s.console, picked.Name, 0o755); err != nil { |
| + warnf(stdout, "not saved (%s): %v", dest, err) |
| + } else { |
| + mutedf(stdout, "saved: %s is now this directory's default account (%s)", picked.Name, dest) |
| + } |
| + } |
| + return apiAcct{Seg: picked.Sqid, Exact: true}, nil |
| +} |
| + |
| +// 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 |
| +// on refusal because every caller is a verb body whose next line would be |
| +// the request itself — there is nothing for it to do with the error but |
| +// print it and stop. |
| +func resolvedAccount(sess *apiSession, raw, rawID *string) apiAcct { |
| + a := apiAccount(raw, rawID) |
| + if a.Seg != "" { |
| + return a |
| + } |
| + if sess == nil { |
| + // Unreachable from any current verb (each guards its mode before |
| + // resolving), and kept LOUD: returning the empty segment would |
| + // build /api/cli/apps//<app> and answer a client-side bug with the |
| + // membership-404 riddle. |
| + log.Fatal("resolvedAccount: no session — a direct-mode path reached API account resolution (a bug in this binary)") |
| + } |
| + r, err := sess.defaultAccount(context.Background()) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + return r |
| +} |
| + |
| // notFoundErr is what every call here returns for a 404 whose body carries |
| // no JSON "error" field. apiAccountApp (internal/console/apicli.go) answers |
| // an unknown account, an unknown app, and someone else's account with the |
diff --git a/apimode_test.go b/apimode_test.go| index 3c3f98f69..5b1fd4415 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -2778,3 +2778,248 @@ func TestAPISessionCreateAppSendsPlace(t *testing.T) { |
| t.Fatalf("empty place must not travel as a non-empty value; got %q", gotPlace) |
| } |
| } |
| + |
| +// accountsConsole is a console stub for the omitted--account resolution |
| +// tests: it serves GET /api/cli/apps with the given status and body, and |
| +// counts how often the endpoint is consulted. |
| +func accountsConsole(t *testing.T, status int, body string) (*apiSession, *int) { |
| + t.Helper() |
| + hits := 0 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/cli/apps" || r.Method != http.MethodGet { |
| + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) |
| + http.NotFound(w, r) |
| + return |
| + } |
| + hits++ |
| + w.WriteHeader(status) |
| + io.WriteString(w, body) |
| + })) |
| + t.Cleanup(ts.Close) |
| + return newTestSession(ts.URL), &hits |
| +} |
| + |
| +func TestDefaultAccountSingleMembership(t *testing.T) { |
| + useTempCredentials(t) |
| + out := captureStdout(t) |
| + s, hits := accountsConsole(t, http.StatusOK, |
| + `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev","role":"owner","apps":[]}]}`) |
| + |
| + a, err := s.defaultAccount(context.Background()) |
| + if err != nil || a.Seg != "bdk" || !a.Exact { |
| + t.Fatalf("one membership = %+v, %v; want its sqid, exact", a, err) |
| + } |
| + if *hits != 1 { |
| + t.Fatalf("endpoint hits = %d, want 1", *hits) |
| + } |
| + if !strings.Contains(out.String(), "account: tom@keymail.dev (bdk)") { |
| + t.Fatalf("the resolved account must be printed; got:\n%s", out.String()) |
| + } |
| +} |
| + |
| +// forceTerminal pins interactiveTerminal for one test — the real check |
| +// reads process file descriptors, which a test controls in neither |
| +// direction (under `go test` stdin is /dev/null, a char device). |
| +func forceTerminal(t *testing.T, tty bool) { |
| + t.Helper() |
| + old := interactiveTerminal |
| + interactiveTerminal = func() bool { return tty } |
| + t.Cleanup(func() { interactiveTerminal = old }) |
| +} |
| + |
| +func TestDefaultAccountSeveralNonTTY(t *testing.T) { |
| + useTempCredentials(t) |
| + captureStdout(t) |
| + forceTerminal(t, false) |
| + s, _ := accountsConsole(t, http.StatusOK, |
| + `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`) |
| + |
| + // No terminal: several memberships take the non-interactive refusal — |
| + // which names every account. |
| + _, err := s.defaultAccount(context.Background()) |
| + want := "you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)" |
| + if err == nil || err.Error() != want { |
| + t.Fatalf("several = %v\nwant %q", err, want) |
| + } |
| +} |
| + |
| +func TestDefaultAccountChooserPicksSavesAndMemoizes(t *testing.T) { |
| + useTempCredentials(t) |
| + out := captureStdout(t) |
| + forceTerminal(t, true) |
| + oldIn := chooserIn |
| + chooserIn = strings.NewReader("2\ny\n") |
| + t.Cleanup(func() { chooserIn = oldIn }) |
| + s, hits := accountsConsole(t, http.StatusOK, |
| + `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`) |
| + |
| + a, err := s.defaultAccount(context.Background()) |
| + if err != nil || a.Seg != "bce" || !a.Exact { |
| + t.Fatalf("chooser pick = %+v, %v; want acme's sqid, exact", a, err) |
| + } |
| + // y saved the pair to ./.carlos/config (cwd is a temp dir here). |
| + cfg, err := loadConfig(filepath.Join(".carlos", "config")) |
| + if err != nil || cfg.Account != "acme" || cfg.Console != s.console { |
| + t.Fatalf("saved config = %+v, %v; want the acme/%s pair", cfg, err, s.console) |
| + } |
| + if !strings.Contains(out.String(), "saved: acme") { |
| + t.Fatalf("the save must be confirmed:\n%s", out.String()) |
| + } |
| + |
| + // Asking again is memoized: same answer, no second fetch, no second |
| + // chooser (the input reader is already drained — a re-prompt would |
| + // EOF into the refusal). |
| + a2, err := s.defaultAccount(context.Background()) |
| + if err != nil || a2 != a { |
| + t.Fatalf("second resolution = %+v, %v; want the memoized first", a2, err) |
| + } |
| + if *hits != 1 { |
| + t.Fatalf("endpoint hits = %d, want 1 — resolution happens once per invocation", *hits) |
| + } |
| +} |
| + |
| +func TestDefaultAccountChooserSaveFailureIsNotFatal(t *testing.T) { |
| + useTempCredentials(t) |
| + out := captureStdout(t) |
| + forceTerminal(t, true) |
| + oldIn := chooserIn |
| + chooserIn = strings.NewReader("1\ny\n") |
| + t.Cleanup(func() { chooserIn = oldIn }) |
| + s, _ := accountsConsole(t, http.StatusOK, |
| + `{"accounts":[{"sqid":"bdk","name":"tom@keymail.dev"},{"sqid":"bce","name":"acme"}]}`) |
| + |
| + // A read-only working directory: the config walk still reads fine |
| + // (nothing exists to read), but MkdirAll(".carlos") cannot create. |
| + cwd, err := os.Getwd() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(cwd, 0o555); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.Chmod(cwd, 0o755) }) |
| + // The human answered; the account is known; the save is bookkeeping. |
| + // A read-only checkout must not turn the answer into a dead ship. |
| + a, err := s.defaultAccount(context.Background()) |
| + if err != nil || a.Seg != "bdk" { |
| + t.Fatalf("pick with failing save = %+v, %v; want the pick to stand", a, err) |
| + } |
| + if !strings.Contains(out.String(), "not saved") { |
| + t.Fatalf("a failed save must be said out loud:\n%s", out.String()) |
| + } |
| +} |
| + |
| +func TestDefaultAccountOldConsoleFallsBackToOps(t *testing.T) { |
| + useTempCredentials(t) |
| + captureStdout(t) |
| + s, _ := accountsConsole(t, http.StatusNotFound, "404 page not found\n") |
| + |
| + a, err := s.defaultAccount(context.Background()) |
| + if err != nil || a.Seg != "ops" || a.Exact { |
| + t.Fatalf("old console = %+v, %v; want the pre-discovery ops default", a, err) |
| + } |
| +} |
| + |
| +func TestDefaultAccountOpsFallbackIsSaidOutLoud(t *testing.T) { |
| + useTempCredentials(t) |
| + out := captureStdout(t) |
| + s, _ := accountsConsole(t, http.StatusNotFound, "404 page not found\n") |
| + |
| + // The fallback must not be silent: an invisible ops followed by the |
| + // membership 404 is the three-failures-deep riddle #157 is about. |
| + if _, err := s.defaultAccount(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.Contains(out.String(), "account: ops") || !strings.Contains(out.String(), s.console) { |
| + t.Fatalf("the ops fallback must name itself and the console:\n%s", out.String()) |
| + } |
| +} |
| + |
| +func TestDefaultAccountZeroMemberships(t *testing.T) { |
| + useTempCredentials(t) |
| + captureStdout(t) |
| + s, _ := accountsConsole(t, http.StatusOK, `{"accounts":[]}`) |
| + |
| + if _, err := s.defaultAccount(context.Background()); err == nil || |
| + !strings.Contains(err.Error(), s.console) { |
| + t.Fatalf("zero memberships = %v, want an error naming the console", err) |
| + } |
| +} |
| + |
| +func TestDefaultAccountAuthErrorSurfaces(t *testing.T) { |
| + useTempCredentials(t) |
| + captureStdout(t) |
| + s, _ := accountsConsole(t, http.StatusUnauthorized, `{"error":"token expired"}`) |
| + |
| + // A 401 is an answer about the TOKEN, not about memberships — it must |
| + // surface, never turn into an ops fallback that then 404s confusingly. |
| + if _, err := s.defaultAccount(context.Background()); err == nil || |
| + !strings.Contains(err.Error(), "token expired") { |
| + t.Fatalf("401 = %v, want the console's own diagnosis", err) |
| + } |
| +} |
| + |
| +func TestDefaultAccountConfigDefault(t *testing.T) { |
| + useTempCredentials(t) |
| + out := captureStdout(t) |
| + s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[]}`) |
| + |
| + // A config account paired with THIS console is honored without asking |
| + // the console anything. |
| + cfgPath := writeConfig(t, ".", `{"console": "`+s.console+`", "account": "acme"}`) |
| + a, err := s.defaultAccount(context.Background()) |
| + if err != nil || a.Seg != "acme" || a.Exact { |
| + t.Fatalf("config account = %+v, %v; want the name, resolved server-side", a, err) |
| + } |
| + if *hits != 0 { |
| + t.Fatalf("endpoint hits = %d, want 0 — config answered it", *hits) |
| + } |
| + if !strings.Contains(out.String(), "acme") || !strings.Contains(out.String(), cfgPath) { |
| + t.Fatalf("the config-sourced account and its file must be printed; got:\n%s", out.String()) |
| + } |
| + |
| + // A config account with NO console is ignored: the pair is one fact, |
| + // and an account key that applied to every console would let a |
| + // checked-in file redirect ships across stacks. With zero memberships |
| + // behind it, resolution reports those — proving the fall-through. |
| + // (Each check below uses a fresh session: resolution memoizes per |
| + // invocation, and these are separate invocations.) |
| + writeConfig(t, ".", `{"account": "acme"}`) |
| + s2 := newTestSession(s.console) |
| + if _, err = s2.defaultAccount(context.Background()); err == nil || |
| + !strings.Contains(err.Error(), "no account") { |
| + t.Fatalf("consoleless config account = %v, want fall-through to memberships", err) |
| + } |
| + if *hits != 1 { |
| + t.Fatalf("endpoint hits = %d, want exactly the fall-through call", *hits) |
| + } |
| + |
| + // Paired with a DIFFERENT console it is ignored the same way. |
| + writeConfig(t, ".", `{"console": "https://other.example", "account": "acme"}`) |
| + s3 := newTestSession(s.console) |
| + if _, err = s3.defaultAccount(context.Background()); err == nil || |
| + !strings.Contains(err.Error(), "no account") { |
| + t.Fatalf("mismatched config console = %v, want fall-through to memberships", err) |
| + } |
| + if *hits != 2 { |
| + t.Fatalf("endpoint hits = %d, want one per fall-through", *hits) |
| + } |
| +} |
| + |
| +func TestResolvedAccountExplicitFlagsBypass(t *testing.T) { |
| + useTempCredentials(t) |
| + captureStdout(t) |
| + s, hits := accountsConsole(t, http.StatusOK, `{"accounts":[]}`) |
| + |
| + raw, rawID := "acme", "" |
| + if a := resolvedAccount(s, &raw, &rawID); a.Seg != "acme" || a.Exact { |
| + t.Fatalf("-account acme = %+v", a) |
| + } |
| + raw, rawID = "", "bdk" |
| + if a := resolvedAccount(s, &raw, &rawID); a.Seg != "bdk" || !a.Exact { |
| + t.Fatalf("-account-id bdk = %+v", a) |
| + } |
| + if *hits != 0 { |
| + t.Fatalf("explicit flags must never consult the endpoint; hits = %d", *hits) |
| + } |
| +} |
diff --git a/appconfig_cli.go b/appconfig_cli.go| index 59b10801a..e9b382c98 100644 |
| --- a/appconfig_cli.go |
| +++ b/appconfig_cli.go |
| @@ -215,7 +215,7 @@ func runEnvSet(args []string) { |
| for k, v := range kv { |
| set[k] = appconfig.Var{Kind: "env", Value: v} |
| } |
| - if _, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, |
| + if _, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, |
| apiConfigApply{Environment: *environment, Set: set}); err != nil { |
| log.Fatal(err) |
| } |
| @@ -247,7 +247,7 @@ func runEnvUnset(args []string) { |
| } |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| - removed, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, |
| + removed, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, |
| apiConfigApply{Environment: *environment, Unset: fs.Args()}) |
| if err != nil { |
| log.Fatal(err) |
| @@ -281,7 +281,7 @@ func runEnvList(args []string) { |
| } |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| - cfg, err := sess.configGet(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, *environment) |
| + cfg, err := sess.configGet(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, *environment) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -397,7 +397,7 @@ func runEnvExecDelivery(args []string) { |
| if value != "" { |
| wire = value |
| } |
| - if _, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, |
| + if _, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, |
| apiConfigApply{ExecDelivery: &wire}); err != nil { |
| log.Fatal(err) |
| } |
| @@ -427,7 +427,7 @@ func runEnvEnvironments(args []string) { |
| } |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| - cfg, err := sess.configGet(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, "") |
| + cfg, err := sess.configGet(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, "") |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -477,7 +477,7 @@ func runEnvSync(args []string) { |
| } |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| - if err := sess.configSync(context.Background(), apiAccount(acctRaw, acctIDRaw), *app); err != nil { |
| + if err := sess.configSync(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app); err != nil { |
| log.Fatal(err) |
| } |
| } else if err := appconfig.TouchGeneration(context.Background(), openStore(), acctOf(), *app); err != nil { |
| @@ -608,7 +608,13 @@ func runSecretsSet(args []string) { |
| } |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| - pub := secretsPubKeyAPI(sess, apiAccount(acctRaw, acctIDRaw), *app) |
| + // Resolved ONCE, used for both the seal key and the write: two |
| + // resolutions here could answer differently (the chooser asked |
| + // twice), sealing the value with one account's public key and |
| + // writing it into another's config — an undecryptable secret whose |
| + // only symptom is a render failure in the hourly adopt log. |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + pub := secretsPubKeyAPI(sess, acct, *app) |
| set := make(map[string]appconfig.Var, len(kv)) |
| for k, v := range kv { |
| env, err := appconfig.Seal(pub, []byte(v)) |
| @@ -617,7 +623,7 @@ func runSecretsSet(args []string) { |
| } |
| set[k] = appconfig.Var{Kind: "secret", Envelope: env} |
| } |
| - if _, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, |
| + if _, err := sess.configApply(context.Background(), acct, *app, |
| apiConfigApply{Environment: *environment, Set: set}); err != nil { |
| log.Fatal(err) |
| } |
| @@ -658,7 +664,7 @@ func runSecretsUnset(args []string) { |
| } |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| - removed, err := sess.configApply(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, |
| + removed, err := sess.configApply(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, |
| apiConfigApply{Environment: *environment, Unset: fs.Args()}) |
| if err != nil { |
| log.Fatal(err) |
| @@ -704,7 +710,7 @@ func runSecretsList(args []string) { |
| } |
| if sess != nil { |
| printUsing(os.Stdout, sess) |
| - cfg, err := sess.configGet(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, *environment) |
| + cfg, err := sess.configGet(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, *environment) |
| if err != nil { |
| log.Fatal(err) |
| } |
diff --git a/apps.go b/apps.go| index 6686af179..baee56d07 100644 |
| --- a/apps.go |
| +++ b/apps.go |
| @@ -75,7 +75,8 @@ const appsUsage = `usage: carlos apps <create|place|delete|restore> [flags] <app |
| — but NO routes come back, not even the platform apex: |
| run "carlos add <host>" afterwards to serve it again. |
| - -account <sqid|name> account the app belongs to (default: ops) — resolved |
| + -account <sqid|name> account the app belongs to (default: your only |
| + account, or the configured/chosen one) — resolved |
| by the console against your own memberships; signed-in |
| mode only. |
| -account-id <sqid> account sqid, matched as a sqid ONLY — the only |
| @@ -199,8 +200,8 @@ func runAppsDelete(args []string) { |
| } |
| if mode == "signed-in" { |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| res, err := sess.trashApp(context.Background(), acct, app) |
| if err != nil { |
| log.Fatal(err) |
| @@ -319,8 +320,8 @@ func runAppsRestore(args []string) { |
| } |
| if mode == "signed-in" { |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| res, err := sess.restoreApp(context.Background(), acct, app) |
| if err != nil { |
| log.Fatal(err) |
| @@ -382,7 +383,7 @@ func runAppsCreate(args []string) { |
| // 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. |
| - acctRaw := fs.String("account", "ops", "account (sqid or name) to claim the app in") |
| + acctRaw := fs.String("account", "", "account (sqid or name) to claim the app in (default: your only account, or the configured/chosen one)") |
| 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 create it through (default: the only logged-in one)") |
| fs.Parse(args) |
| @@ -415,8 +416,8 @@ func runAppsCreate(args []string) { |
| "box-side way to hand a name to an address. To claim through a console instead,\n" + |
| "unset the store env and run: carlos auth login -console <url>") |
| } |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| res, err := sess.createApp(context.Background(), acct, strings.TrimSpace(*app), place2) |
| if err != nil { |
| log.Fatalf("apps create: %v", err) |
| @@ -475,7 +476,7 @@ func runAppsPlace(args []string) { |
| // 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") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| 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) |
| @@ -519,7 +520,7 @@ func runAppsPlace(args []string) { |
| "(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) |
| + acct := resolvedAccount(sess, 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) |
diff --git a/auth.go b/auth.go| index ade8dfe54..de3a1e22e 100644 |
| --- a/auth.go |
| +++ b/auth.go |
| @@ -732,6 +732,21 @@ func authWhoami(out io.Writer, args []string) error { |
| if who.ExpiresAt != "" { |
| mutedf(out, " expires: %s", who.ExpiresAt) |
| } |
| + // Memberships are a bonus line, not part of whoami's contract: identity |
| + // already printed, and a console from before GET /api/cli/apps (or a |
| + // blip fetching it) must not make whoami look broken — so ANY error |
| + // here degrades to silence rather than a message. |
| + var acc struct { |
| + Accounts []apiAccountEntry `json:"accounts"` |
| + } |
| + if _, err := newConsoleClient(c.Console, c.Token). |
| + do(context.Background(), http.MethodGet, "/api/cli/apps", nil, &acc); err == nil && len(acc.Accounts) > 0 { |
| + rows := make([]string, 0, len(acc.Accounts)) |
| + for _, a := range acc.Accounts { |
| + rows = append(rows, fmt.Sprintf("%s (%s)", a.Name, a.Sqid)) |
| + } |
| + mutedf(out, " accounts: %s", strings.Join(rows, ", ")) |
| + } |
| return nil |
| } |
diff --git a/auth_test.go b/auth_test.go| index bb8a50c74..6ace3ddac 100644 |
| --- a/auth_test.go |
| +++ b/auth_test.go |
| @@ -182,13 +182,14 @@ func TestPollBoundsAreEnforced(t *testing.T) { |
| // fakeConsole answers the CLI endpoints the way the real console does, so the |
| // login loop can be driven end to end without a browser. |
| type fakeConsole struct { |
| - ts *httptest.Server |
| - polls atomic.Int32 |
| - approved atomic.Bool |
| - denied atomic.Bool |
| - revoked atomic.Bool |
| - lastAuth atomic.Value // string: the bearer the last request carried |
| - label atomic.Value // string |
| + ts *httptest.Server |
| + polls atomic.Int32 |
| + approved atomic.Bool |
| + denied atomic.Bool |
| + revoked atomic.Bool |
| + noAccounts atomic.Bool // serve 404 on GET /api/cli/apps: a pre-5856dc5 console |
| + lastAuth atomic.Value // string: the bearer the last request carried |
| + label atomic.Value // string |
| } |
| func newFakeConsole(t *testing.T) *fakeConsole { |
| @@ -237,6 +238,21 @@ func newFakeConsole(t *testing.T) *fakeConsole { |
| "expires_at": time.Now().Add(90 * 24 * time.Hour).UTC().Format(time.RFC3339), |
| }) |
| }) |
| + mux.HandleFunc("GET /api/cli/apps", func(w http.ResponseWriter, r *http.Request) { |
| + if fc.noAccounts.Load() { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if r.Header.Get("Authorization") != "Bearer cli-token" { |
| + w.WriteHeader(http.StatusUnauthorized) |
| + json.NewEncoder(w).Encode(map[string]string{"error": "not signed in"}) |
| + return |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{"accounts": []map[string]any{ |
| + {"sqid": "bab", "name": "paul@keymail.dev", "role": "owner", "apps": []string{}}, |
| + {"sqid": "bce", "name": "acme", "role": "member", "apps": []string{"shop"}}, |
| + }}) |
| + }) |
| mux.HandleFunc("POST /auth/cli/revoke", func(w http.ResponseWriter, r *http.Request) { |
| if r.Header.Get("Authorization") != "Bearer cli-token" { |
| w.WriteHeader(http.StatusUnauthorized) |
| @@ -325,6 +341,41 @@ func TestAuthLoginStoresACredential(t *testing.T) { |
| } |
| } |
| +// whoami closes the loop #157 opened: who am I, AND what can I act on. |
| +// Memberships come from GET /api/cli/apps; a console too old to answer |
| +// (bare 404) must not make whoami look broken — identity prints, the |
| +// bonus line is simply absent. |
| +func TestAuthWhoamiPrintsMemberships(t *testing.T) { |
| + useTempCredentials(t) |
| + fc := newFakeConsole(t) |
| + var out bytes.Buffer |
| + if err := authLogin(&out, []string{"-console", fc.ts.URL}); err != nil { |
| + t.Fatalf("authLogin: %v", err) |
| + } |
| + |
| + out.Reset() |
| + if err := authWhoami(&out, []string{"-console", fc.ts.URL}); err != nil { |
| + t.Fatalf("authWhoami: %v", err) |
| + } |
| + if !strings.Contains(out.String(), "accounts: paul@keymail.dev (bab), acme (bce)") { |
| + t.Fatalf("whoami must list memberships:\n%s", out.String()) |
| + } |
| + |
| + // Same command against a console that predates the endpoint: identity |
| + // only, silently — no error, no accounts line. |
| + fc.noAccounts.Store(true) |
| + out.Reset() |
| + if err := authWhoami(&out, []string{"-console", fc.ts.URL}); err != nil { |
| + t.Fatalf("authWhoami against an old console: %v", err) |
| + } |
| + if !strings.Contains(out.String(), "paul@keymail.dev at "+fc.ts.URL) { |
| + t.Fatalf("identity line missing:\n%s", out.String()) |
| + } |
| + if strings.Contains(out.String(), "accounts:") { |
| + t.Fatalf("old console must degrade silently:\n%s", out.String()) |
| + } |
| +} |
| + |
| func TestAuthLoginDenied(t *testing.T) { |
| useTempCredentials(t) |
| fc := newFakeConsole(t) |
diff --git a/cli.go b/cli.go| index 64b460d6a..d8e809046 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -1552,7 +1552,11 @@ func routeVersionCheck(ctx context.Context, store release.Store, pub ed25519.Pub |
| // string-coupled to this function's internal flag name the way |
| // fs.Lookup("account") would be. |
| func accountFlags(fs *flag.FlagSet) (raw, rawID *string, resolve func() string) { |
| - acct := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + // Empty default, two meanings by mode: API mode resolves it to the |
| + // caller's own account (resolvedAccount, apimode.go); direct mode's |
| + // resolveAccount reads "" as ops exactly as it always has, so box and |
| + // script behavior is unchanged. |
| + acct := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one; ops in direct mode)") |
| acctID := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account; this is the flag an ambiguity error tells you to use") |
| regPath := fs.String("registry", defaultRegistry(), "registry database (resolves -account)") |
| return acct, acctID, func() string { return resolveAccount(*regPath, *acct, *acctID) } |
| @@ -1688,13 +1692,19 @@ func runShip(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - // apiAccount is resolved HERE, not inside runShipAPI: it fatals on a |
| - // name in -account-id, and log.Fatal skips every deferred function — |
| - // which is the one thing runShipAPI exists to avoid (its static |
| - // branch defers the cleanup of a scratch tarball up to 256MiB). |
| - // Evaluated as an argument, the refusal lands before runShipAPI's |
| - // body has registered a defer at all. |
| - if err := runShipAPI(sess, apiAccount(acctRaw, acctIDRaw), fs, *app, *ver, *kind, *target, *label, noteText, capsSet, true); err != nil { |
| + // The banner prints BEFORE the account resolves: resolution may put |
| + // a muted "account: …" line — or the whole interactive chooser — on |
| + // the terminal, and a prompt with no console named yet is a question |
| + // with no context. The account is still resolved HERE, not inside |
| + // runShipAPI: resolvedAccount fatals (on a name in -account-id, or |
| + // an ambiguous omitted -account), and log.Fatal skips every deferred |
| + // function — which is the one thing runShipAPI exists to avoid (its |
| + // static branch defers the cleanup of a scratch tarball up to |
| + // 256MiB). Resolved before the call, the refusal lands before |
| + // runShipAPI's body has registered a defer at all. |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + if err := runShipAPI(sess, acct, fs, *app, *ver, *kind, *target, *label, noteText, capsSet, false); err != nil { |
| log.Fatal(err) |
| } |
| return |
| @@ -1958,8 +1968,8 @@ func runPromote(args []string) { |
| // flag silently do nothing. |
| log.Fatal("hotfix is an operator act — use the direct path (CARLOS_DEPLOYMENT_BUCKET) or the console") |
| } |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| res, err := sess.promote(context.Background(), acct, *app, version, channel) |
| if err != nil { |
| log.Fatal(err) |
| @@ -2034,8 +2044,8 @@ func runChannels(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| chans, err := sess.channels(context.Background(), acct, *app) |
| if err != nil { |
| log.Fatal(err) |
| @@ -2131,8 +2141,8 @@ func runVersionTarget(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| if fs.NArg() == 0 { |
| vt, err := sess.versionTarget(context.Background(), acct, *app) |
| if err != nil { |
| @@ -2276,8 +2286,8 @@ func runReleases(args []string) { |
| } |
| var rows []releaseRow |
| if sess != nil { |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| rels, err := sess.releases(context.Background(), acct, *app) |
| if err != nil { |
| log.Fatal(err) |
| @@ -2323,8 +2333,8 @@ func runRollback(args []string) { |
| log.Fatal(err) |
| } |
| if sess != nil { |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| if err := sess.rollback(context.Background(), acct, *app, fs.Arg(0)); err != nil { |
| log.Fatal(err) |
| } |
| @@ -2444,7 +2454,7 @@ func ledgerAPISession(consoleFlag string) *apiSession { |
| // sqid only, so the console's own symmetric resolution can never name-match |
| // it. |
| func ledgerAccountFlags(fs *flag.FlagSet) (raw, rawID *string) { |
| - return fs.String("account", "ops", "account (sqid or name) the app belongs to — resolved by the console"), |
| + return fs.String("account", "", "account (sqid or name) the app belongs to — resolved by the console (default: your only account, or the configured/chosen one)"), |
| fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| } |
| @@ -2481,7 +2491,7 @@ func runLedgerAppend(args []string) { |
| sess := ledgerAPISession(*consoleFlag) |
| printUsing(stdout, sess) |
| - a, err := sess.ledgerAppend(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, name, json.RawMessage(raw)) |
| + a, err := sess.ledgerAppend(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, name, json.RawMessage(raw)) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -2507,7 +2517,7 @@ func runLedgerHead(args []string) { |
| } |
| sess := ledgerAPISession(*consoleFlag) |
| - h, err := sess.ledgerHead(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, name) |
| + h, err := sess.ledgerHead(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, name) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -2530,7 +2540,7 @@ func runLedgerList(args []string) { |
| sess := ledgerAPISession(*consoleFlag) |
| printUsing(stdout, sess) |
| - body, err := sess.ledgerList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app) |
| + body, err := sess.ledgerList(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -2587,7 +2597,7 @@ func runLedgerPublish(args []string) { |
| sess := ledgerAPISession(*consoleFlag) |
| printUsing(stdout, sess) |
| m := ledger.Manifest{Public: *public, Ledgers: names, Title: *title} |
| - got, err := sess.ledgerPublish(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, m) |
| + got, err := sess.ledgerPublish(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, m) |
| if err != nil { |
| log.Fatal(err) |
| } |
| @@ -2633,7 +2643,7 @@ func runLedgerBlob(args []string) { |
| if len(data) > ledger.MaxBlob { |
| log.Fatalf("ledger blob: %s is %d bytes, exceeds the %d byte cap", path, len(data), ledger.MaxBlob) |
| } |
| - sha, err := sess.ledgerBlob(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, name, data) |
| + sha, err := sess.ledgerBlob(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, name, data) |
| if err != nil { |
| log.Fatal(err) |
| } |
diff --git a/config.go b/config.go| index b11c3171c..2b5c60370 100644 |
| --- a/config.go |
| +++ b/config.go |
| @@ -34,38 +34,52 @@ import ( |
| // only; no other verb ever assumes it. |
| const defaultConsole = "https://platform.carlosframework.com" |
| -// cliConfig is the whole file. Unknown fields are ignored on read and |
| -// dropped on rewrite: the file is additive, and the CLI is its only parser. |
| +// cliConfig is the whole file. Unknown fields are ignored on read and — since |
| +// the account key landed — PRESERVED on rewrite (writeConfigKeys): the file |
| +// is additive, other tools may learn to read it, and a rewrite that drops |
| +// what it doesn't understand makes every future key lossy against the binary |
| +// that predates it. |
| type cliConfig struct { |
| Console string `json:"console"` |
| + Account string `json:"account"` |
| } |
| -// loadConfigFile reads one .carlos/config. A missing file, or one without a |
| -// console, is ("", nil): "no default at this level", and resolution |
| -// continues. A file that cannot be parsed, or names a console |
| +// 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 |
| // config would ship to the wrong console, the exact mistake this file |
| -// exists to prevent. |
| -func loadConfigFile(path string) (string, error) { |
| +// exists to prevent. The console comes back normalized ("" when unset); |
| +// the account comes back trimmed. |
| +func loadConfig(path string) (cliConfig, error) { |
| raw, err := os.ReadFile(path) |
| if errors.Is(err, os.ErrNotExist) { |
| - return "", nil |
| + return cliConfig{}, nil |
| } |
| if err != nil { |
| - return "", err |
| + return cliConfig{}, err |
| } |
| var cfg cliConfig |
| if err := json.Unmarshal(raw, &cfg); err != nil { |
| - return "", fmt.Errorf("%s is not readable as a carlos config: %w", path, err) |
| + return cliConfig{}, fmt.Errorf("%s is not readable as a carlos config: %w", path, err) |
| } |
| if strings.TrimSpace(cfg.Console) == "" { |
| - return "", nil |
| - } |
| - n, err := normalizeConsole(cfg.Console) |
| - if err != nil { |
| - return "", fmt.Errorf("%s: %w", path, err) |
| + cfg.Console = "" |
| + } else { |
| + n, err := normalizeConsole(cfg.Console) |
| + if err != nil { |
| + return cliConfig{}, fmt.Errorf("%s: %w", path, err) |
| + } |
| + cfg.Console = n |
| } |
| - return n, nil |
| + cfg.Account = strings.TrimSpace(cfg.Account) |
| + return cfg, nil |
| +} |
| + |
| +// loadConfigFile is loadConfig's console-only view — the shape every |
| +// console-default caller predating the account key still reads. |
| +func loadConfigFile(path string) (string, error) { |
| + cfg, err := loadConfig(path) |
| + return cfg.Console, err |
| } |
| // configDefault resolves the configured default console: the nearest |
| @@ -111,6 +125,49 @@ func configDefault() (console, source string, err error) { |
| return c, path, nil |
| } |
| +// configAccount resolves the configured default ACCOUNT: the nearest |
| +// .carlos/config walking up from the working directory whose account key is |
| +// set, else ~/.carlos/config. A level that names only a console does not |
| +// stop this walk — console and account are separate defaults that happen to |
| +// share a file. Returns the account, the file it came from, and that SAME |
| +// file's console ("" when it names none): the pairing rule lives at the |
| +// caller (apimode.go defaultAccount), which honors a file's account only |
| +// for the console the file itself names — a config pair points at one |
| +// 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 { |
| + return "", "", "", err |
| + } |
| + if cfg.Account == "" { |
| + return "", "", "", nil |
| + } |
| + return cfg.Account, path, cfg.Console, nil |
| +} |
| + |
| // globalConfigPath is ~/.carlos/config — the same directory that holds |
| // credentials, though this file is not a secret and never holds one. |
| func globalConfigPath() (string, error) { |
| @@ -121,22 +178,54 @@ func globalConfigPath() (string, error) { |
| return filepath.Join(home, ".carlos", "config"), nil |
| } |
| -// saveConfigFile validates the console and writes {"console": …} at path, |
| -// creating the .carlos directory (dirPerm: 0700 for ~/.carlos, which |
| -// MkdirAll leaves alone if it already exists; 0755 for a project directory |
| -// that is meant to be committed). Temp-file-then-rename like |
| -// credentialFile.save, so a crash cannot leave a truncated config — but |
| -// 0644, not 0600: this file is not a secret. |
| +// saveConfigFile validates the console and writes the "console" key at |
| +// path, preserving everything else the file holds (writeConfigKeys). |
| +// dirPerm: 0700 for ~/.carlos, which MkdirAll leaves alone if it already |
| +// exists; 0755 for a project directory that is meant to be committed. |
| func saveConfigFile(path, console string, dirPerm os.FileMode) error { |
| n, err := normalizeConsole(console) |
| if err != nil { |
| return err |
| } |
| + return writeConfigKeys(path, map[string]any{"console": n}, dirPerm) |
| +} |
| + |
| +// saveConfigAccount writes a console+account pair — the chooser's "save as |
| +// this project's default" answer. The two keys land together deliberately: |
| +// configAccount's pairing rule reads them as one fact ("this checkout means |
| +// this account on this console"), so saving the account without pinning the |
| +// console would leave a default that silently stops applying the day the |
| +// directory's console default changes. |
| +func saveConfigAccount(path, console, account string, dirPerm os.FileMode) error { |
| + n, err := normalizeConsole(console) |
| + if err != nil { |
| + return err |
| + } |
| + return writeConfigKeys(path, map[string]any{"console": n, "account": account}, dirPerm) |
| +} |
| + |
| +// writeConfigKeys rewrites path with the given keys set, keeping every |
| +// field it does not understand — a rewrite that drops unknown keys makes |
| +// every future config key lossy against the binary that predates it. |
| +// Temp-file-then-rename like credentialFile.save, so a crash cannot leave |
| +// a truncated config — but 0644, not 0600: this file is not a secret. |
| +func writeConfigKeys(path string, set map[string]any, dirPerm os.FileMode) error { |
| dir := filepath.Dir(path) |
| if err := os.MkdirAll(dir, dirPerm); err != nil { |
| return err |
| } |
| - body, err := json.MarshalIndent(cliConfig{Console: n}, "", " ") |
| + all := map[string]any{} |
| + if raw, err := os.ReadFile(path); err == nil { |
| + if err := json.Unmarshal(raw, &all); err != nil { |
| + return fmt.Errorf("%s is not readable as a carlos config: %w", path, err) |
| + } |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } |
| + for k, v := range set { |
| + all[k] = v |
| + } |
| + body, err := json.MarshalIndent(all, "", " ") |
| if err != nil { |
| return err |
| } |
diff --git a/config_test.go b/config_test.go| index 4e835c085..54bf00f56 100644 |
| --- a/config_test.go |
| +++ b/config_test.go |
| @@ -2,6 +2,7 @@ package main |
| import ( |
| "bytes" |
| + "encoding/json" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -325,3 +326,98 @@ func TestAuthDefaultVerb(t *testing.T) { |
| t.Fatal("two positional args must be a usage error") |
| } |
| } |
| + |
| +func TestConfigAccountWalk(t *testing.T) { |
| + useTempCredentials(t) |
| + home := os.Getenv("HOME") |
| + |
| + // Nothing anywhere: no account default, no error. |
| + if a, src, cons, err := configAccount(); err != nil || a != "" || src != "" || cons != "" { |
| + t.Fatalf("bare configAccount = %q %q %q %v, want empty", a, src, cons, err) |
| + } |
| + |
| + // Only the global, and the file names no console: a pure account |
| + // default, fileConsole empty. |
| + globalPath := writeConfig(t, home, `{"account": "acme"}`) |
| + a, src, cons, err := configAccount() |
| + if err != nil || a != "acme" || src != globalPath || cons != "" { |
| + t.Fatalf("global-only = %q %q %q %v", a, src, cons, err) |
| + } |
| + |
| + // A nearer file with only a console does not stop the ACCOUNT walk. |
| + root := t.TempDir() |
| + writeConfig(t, root, `{"console": "https://outer.example"}`) |
| + nested := filepath.Join(root, "cmd", "deep") |
| + if err := os.MkdirAll(nested, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Chdir(nested) |
| + if a, _, _, err = configAccount(); err != nil || a != "acme" { |
| + t.Fatalf("console-only level = %q %v, want the walk to continue to acme", a, err) |
| + } |
| + |
| + // The nearest account-bearing file wins and reports ITS console — |
| + // normalized, because the pairing rule compares it to a session console. |
| + nearest := writeConfig(t, nested, `{"console": "https://inner.example/", "account": "tools"}`) |
| + a, src, cons, err = configAccount() |
| + if err != nil || a != "tools" || src != nearest || cons != "https://inner.example" { |
| + t.Fatalf("nearest = %q %q %q %v", a, src, cons, err) |
| + } |
| + |
| + // Malformed config mid-walk is an error, not a skip. |
| + os.WriteFile(nearest, []byte(`not json`), 0o644) |
| + if _, _, _, err = configAccount(); err == nil { |
| + t.Fatal("malformed project config must be an error, not a skip") |
| + } |
| +} |
| + |
| +func TestConfigRewritesPreserveUnknownFields(t *testing.T) { |
| + useTempCredentials(t) |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, ".carlos", "config") |
| + |
| + // A fresh saveConfigAccount writes both keys. |
| + if err := saveConfigAccount(path, "https://tito.example/", "acme", 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg, err := loadConfig(path) |
| + if err != nil || cfg.Console != "https://tito.example" || cfg.Account != "acme" { |
| + t.Fatalf("fresh save = %+v, %v", cfg, err) |
| + } |
| + |
| + // A file holding a key from the future keeps it across BOTH rewrites — |
| + // the old drop-on-rewrite behavior made every new key lossy. |
| + if err := os.WriteFile(path, []byte(`{"console":"https://tito.example","future_key":42}`), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := saveConfigAccount(path, "https://tito.example", "tools", 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var all map[string]any |
| + if err := json.Unmarshal(raw, &all); err != nil { |
| + t.Fatal(err) |
| + } |
| + if all["future_key"] != float64(42) || all["account"] != "tools" { |
| + t.Fatalf("saveConfigAccount dropped fields: %v", all) |
| + } |
| + if err := saveConfigFile(path, "https://other.example/", 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _ = os.ReadFile(path) |
| + all = map[string]any{} |
| + if err := json.Unmarshal(raw, &all); err != nil { |
| + t.Fatal(err) |
| + } |
| + if all["future_key"] != float64(42) || all["account"] != "tools" || all["console"] != "https://other.example" { |
| + t.Fatalf("saveConfigFile dropped fields: %v", all) |
| + } |
| + |
| + // The console is still validated on write, account or not. |
| + if err := saveConfigAccount(path, "ftp://nope", "x", 0o755); err == nil { |
| + t.Fatal("saving an invalid console must fail") |
| + } |
| +} |
diff --git a/deploy.go b/deploy.go| index 9b1693322..14327c5ac 100644 |
| --- a/deploy.go |
| +++ b/deploy.go |
| @@ -59,8 +59,8 @@ func runDeploy(args []string) { |
| } |
| sess := requireDeployAPISession(*consoleFlag) // API-mode only: member verb |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| - printUsing(stdout, sess) // printed HERE, once: runShipAPI's own banner is suppressed below |
| + printUsing(stdout, sess) // printed HERE, once: runShipAPI's own banner is suppressed below |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| ctx := context.Background() |
| recs, _, _, err := sess.instanceList(ctx, acct, *app, "", "") |
| if err != nil { |
diff --git a/docs/superpowers/plans/2026-08-12-account-default-resolution.md b/docs/superpowers/plans/2026-08-12-account-default-resolution.md| new file mode 100644 |
| index 000000000..bab46d065 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-12-account-default-resolution.md |
| @@ -0,0 +1,122 @@ |
| +# Account Default Resolution Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** When `-account` is omitted in API mode, resolve it from config or the caller's memberships (one → use it; several → interactive chooser with a save-to-project prompt), and expose memberships via `GET /api/cli/apps` (already live), `carlos accounts list`, and `carlos auth whoami`. Closes #157. |
| + |
| +**Architecture:** One new token-gated console endpoint returning `Store.AccountsFor(addr)`. CLI-side, a single resolution seam on `apiSession` (`defaultAccount`) called through a `resolvedAccount(sess, raw, rawID)` wrapper that replaces `apiAccount(...)` at every API-mode callsite; flag defaults flip `"ops"` → `""` (direct mode already reads `""` as ops, so boxes are untouched). |
| + |
| +**Tech Stack:** Go stdlib only; existing test fixtures (`internal/console` fx, CLI httptest patterns, `carlosTestEnv`). |
| + |
| +## Global Constraints |
| + |
| +- `go build ./...`, `go vet ./...`, `go test ./... -count=1` clean; `gofmt -l` clean for touched files; `GOOS=darwin` compile passes (CLI runs on Macs — no Linux-only assumptions). |
| +- Sandbox env for go commands: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`. |
| +- Wire changes additive only; membership 404 opacity byte-identical; direct/bucket mode behavior unchanged. |
| +- Registry-mode verbs (`carlos route`, `carlos add`, `accounts list` on a box) keep the `"ops"` default and local resolution. |
| +- Commits per task; spec: `docs/superpowers/specs/2026-08-12-account-default-resolution-design.md`. |
| + |
| +--- |
| + |
| +### Task 1: ~~Console endpoint~~ SUPERSEDED — endpoint already exists |
| + |
| +`GET /api/cli/apps` (handleAPIApps, internal/console/authcli.go:219, live |
| +since main 5856dc5 2026-08-03, deployed both consoles) already returns |
| +`{"accounts":[{"sqid","name","role","apps"}]}` from `Store.AccountsFor`, |
| +leak-tested by TestAPIAppsListsMemberships. No console change, no console |
| +deploy. Task 4's `listAccounts` decodes this shape; the 404 fallback covers |
| +only pre-2026-08-03 consoles. |
| + |
| +### Task 2: Config `account` key + lossless rewrites |
| + |
| +**Files:** |
| +- Modify: `config.go` (`cliConfig` :39, `loadConfigFile` :50, `saveConfigFile` :124) |
| +- Test: `config_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `cliConfig.Account`; `configAccount() (name, source, fileConsole string, err error)` — nearest `.carlos/config` walking up (then global) **whose `account` is set**; `fileConsole` is that same file's normalized console ("" if unset). `saveConfigFile(path, console, perm)` and new `saveConfigAccount(path, console, account string, dirPerm os.FileMode)` both preserve unknown JSON fields on rewrite. |
| + |
| +- [ ] **Step 1: failing tests** — `account` key round-trips; console-only files return `"" ` from `configAccount` at that level and the walk continues; nearest account-bearing file wins and reports ITS console; rewrite of a file containing `{"console":…,"future_key":42}` keeps `future_key`; `saveConfigAccount` writes both keys with the file's dir at the given perm. |
| +- [ ] **Step 2: run** `go test . -run Config -count=1` → FAIL. |
| +- [ ] **Step 3: implement** — `Account string \`json:"account"\`` on `cliConfig`; refactor the read path to return the whole `cliConfig` (keep `loadConfigFile`'s console-normalization errors verbatim); `configAccount` mirrors `configDefault`'s walk but keys off `Account != ""`; rewrites read the existing file into `map[string]any` (missing file → empty map), set `"console"`/`"account"`, `json.MarshalIndent`, write with the existing perms logic. |
| +- [ ] **Step 4: pass**; **Step 5: commit** `cli: .carlos/config account key; rewrites preserve unknown fields`. |
| + |
| +### Task 3: Interactive chooser |
| + |
| +**Files:** |
| +- Create: `account_choose.go`, `account_choose_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `charDevice` (color.go :93 — read its exact signature first and adapt). |
| +- Produces: `type apiAccountEntry struct{ Name, Sqid string }` (JSON tags `name`/`sqid` — Task 4 decodes into it); `chooseAccount(in io.Reader, out io.Writer, console string, accts []apiAccountEntry) (picked apiAccountEntry, save bool, err error)`; `interactiveTerminal() bool` (stdin AND stdout are char devices); `severalAccountsErr(accts []apiAccountEntry) error` producing exactly: `you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)`. |
| + |
| +- [ ] **Step 1: failing tests** — pick "2\n" returns accts[1]; junk then "1\n" re-prompts once; junk twice → `severalAccountsErr` text; EOF → same; save prompt: "y\n" → save=true, "\n"/"n\n" → false; output contains the numbered list and `account [1-2]:`. |
| +- [ ] **Step 2: run** → FAIL. **Step 3: implement** with `bufio.NewScanner(in)`, prompt loop (2 attempts), then `save <name> as this project's default account? [y/N]` reading one more line, `strings.EqualFold(answer, "y")`. |
| +- [ ] **Step 4: pass**; **Step 5: commit** `cli: interactive account chooser`. |
| + |
| +### Task 4: Resolution seam on apiSession |
| + |
| +**Files:** |
| +- Modify: `apimode.go` (near `apiAccount` :202) |
| +- Test: `apimode_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `s.getJSON`, `apiErrorFromBody`, Task 2 `configAccount`, Task 3 chooser/entry/refusal, `saveConfigAccount`, `mutedf`, package `stdout`. |
| +- Produces: |
| + |
| +```go |
| +func (s *apiSession) listAccounts(ctx context.Context) (accts []apiAccountEntry, supported bool, err error) |
| +// 404 → (nil, false, nil): console predates the endpoint. Non-200/404 → apiErrorFromBody. |
| + |
| +func (s *apiSession) defaultAccount(ctx context.Context) (apiAcct, error) |
| +// order: configAccount (honored iff fileConsole=="" || fileConsole==s.console; |
| +// prints muted `account: <name> (the default from <source>)`) → listAccounts: |
| +// !supported → apiAcct{Seg:"ops"}; 0 → error naming s.console; 1 → muted |
| +// `account: <name> (<sqid>)`, apiAcct{Seg: sqid, Exact: true}; several → |
| +// interactiveTerminal() ? chooser (on save=true saveConfigAccount( |
| +// filepath.Join(".carlos","config"), s.console, picked.Name, 0o755) + muted |
| +// confirmation) : severalAccountsErr. Chosen entry returns Seg=sqid, Exact. |
| + |
| +func resolvedAccount(sess *apiSession, raw, rawID *string) apiAcct |
| +// apiAccount(raw, rawID); if Seg=="" && sess!=nil → sess.defaultAccount |
| +// (context.Background()), log.Fatal on error. sess==nil callers never reach |
| +// it (verbs split modes before touching it). |
| +``` |
| + |
| +- [ ] **Step 1: failing tests** — httptest console serving `/api/cli/apps`: one account → Seg=sqid+Exact; several (non-TTY test env) → `severalAccountsErr` text; 404 → `{Seg:"ops"}`; explicit `-account`/`-account-id` never call the endpoint (assert via handler hit-counter); config account + matching console → its name, endpoint untouched; config console mismatch → falls through to memberships; expired-token 401 surfaces as an error, not a fallback. |
| +- [ ] **Step 2: run** → FAIL. **Step 3: implement.** **Step 4: pass.** **Step 5: commit** `cli: resolve omitted -account from config or memberships`. |
| + |
| +### Task 5: Flag-default flip + callsite sweep |
| + |
| +**Files:** |
| +- Modify: `cli.go` (:1555 accountFlags, :2447 ledgerAccountFlags, ship :1697, promote/rollback/channels/versions/tags sites :1961–:2326, ledger sites :2484–:2636), `apps.go` (:202, :322, :385, :418, :478, :522), `appconfig_cli.go` (14 sites), `deploy.go` :62, `restart.go` :41, `store.go` (4), `retention.go` :54, `instances_cli.go` (:244, :267, :307 + sites), `fleets.go` (6 defaults + 6 sites), `logs.go` (:70, :95) |
| +- **Leave alone:** `cli.go:72` and `cli.go:403` (registry-mode `route`/list verbs — genuine ops default). |
| + |
| +**Interfaces:** consumes Task 4's `resolvedAccount` only. |
| + |
| +- [ ] **Step 1:** flip every listed `fs.String("account", "ops", …)` to `fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")` — matching each file's existing help-string wording style; same for `accountFlags`/`ledgerAccountFlags`. |
| +- [ ] **Step 2:** replace every listed `apiAccount(acctRaw, acctIDRaw)` with `resolvedAccount(sess, acctRaw, acctIDRaw)`. Dual-mode verbs (ship, promote, etc. via `accountFlags`) only hit this on their `sess != nil` branch; their direct branch keeps `acctOf()` → `resolveAccount` (`""` → ops, unchanged). |
| +- [ ] **Step 3:** `go build ./... && go vet ./... && go test ./... -count=1` full-suite — existing tests that shipped with `-account` omitted against httptest consoles will exercise the new fallback; fix any that asserted the literal `ops` segment by adding the endpoint 404 (fallback keeps them green) or an explicit `-account ops`. |
| +- [ ] **Step 4: commit** `cli: -account defaults to your own account in API mode`. |
| + |
| +### Task 6: whoami memberships |
| + |
| +**Files:** modify `auth.go` (`authWhoami` :702), test in `auth_e2e_test.go` or the httptest pattern used by whoami today. |
| + |
| +- [ ] **Step 1: failing test** — console serving both endpoints: output contains `accounts: tom@keymail.dev (bdk), acme (bce)`; console without the endpoint → whoami output unchanged, exit 0. |
| +- [ ] **Step 2:** after the identity print, `newConsoleClient(c.Console, c.Token).do(ctx, GET, "/api/cli/apps", nil, &struct{ Accounts []apiAccountEntry }{…})`; on ANY error print nothing; else one muted line `accounts: n1 (s1), n2 (s2)`. |
| +- [ ] **Step 3: pass; commit** `cli: whoami prints account memberships`. |
| + |
| +### Task 7: accounts list signed-in mode |
| + |
| +**Files:** modify `accounts.go` (`runAccountsList` :204), test in `accounts` CLI test file (follow existing patterns; shell-outs use `carlosTestEnv` + scratch `cmd.Dir`). |
| + |
| +- [ ] **Step 1: failing test** — with no registry file at `-registry` and a credential for an httptest console: NAME/SQID table of the caller's accounts; with a registry file present: today's registry table (existing behavior test stays green); old console (404) → error naming the console as predating account listing. |
| +- [ ] **Step 2:** add `-console` flag; if `*consoleFlag == ""` and `os.Stat(*regPath)` succeeds → existing registry path verbatim; else `loggedInCredential(*consoleFlag)` (fatal on err), GET `/api/cli/apps`, on 404 fatal `"%s does not support account listing — it predates this CLI"`, else `newTable` NAME/SQID. |
| +- [ ] **Step 3: pass; commit** `cli: accounts list works signed-in (#157)`. |
| + |
| +### Task 8: Gate + PR |
| + |
| +- [ ] Full gate: build, vet, `go test ./... -count=1`, `gofmt -l` on touched files, `GOOS=darwin go build ./...`. |
| +- [ ] Opus review of the console endpoint + resolution seam (security-touching per delegation policy); fix findings. |
| +- [ ] Push branch, PR titled `CLI: default -account to your own account; membership discovery (#157)`, body maps spec sections to tasks, `Closes #157`. |
diff --git a/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md b/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md| new file mode 100644 |
| index 000000000..b4e6bf2c7 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-12-account-default-resolution-design.md |
| @@ -0,0 +1,182 @@ |
| +# Account default resolution and membership discovery |
| + |
| +**Date:** 2026-08-12 · **Issue:** #157 · **Status:** approved (Paul, in-session) |
| + |
| +## Problem |
| + |
| +Every API-mode verb defaults `-account` to `ops` — an account that does not |
| +exist on real consoles — so a new user's first ship answers the deliberately |
| +opaque membership 404. Worse, nothing exposes a signed-in caller's own |
| +memberships: `carlos accounts list` opens the registry DB on local disk, and |
| +`carlos auth whoami` reports identity only. A user who omits `-account` is |
| +guessing at a value the console knew all along (Tom, 2026-08-11: first ship |
| +on console.carloku.com, three failures deep before learning his account is |
| +named by his sign-in address). |
| + |
| +Per the no-infra rule, the CLI must answer this itself: discover the |
| +caller's accounts, and when the answer is unambiguous, just use it. |
| + |
| +## Decisions (Paul, 2026-08-12) |
| + |
| +- **One membership → it is the default.** No flag needed. |
| +- **Several memberships → interactive chooser** on a terminal; **refuse and |
| + list** when stdin/stdout is not a TTY (a script cannot answer a prompt; |
| + refusing loudly beats hanging, and the refusal doubles as discovery). |
| +- **Chooser offers to save** the pick as the project's default account |
| + (`./.carlos/config`), one y/N prompt. |
| +- `carlos auth whoami` prints memberships; `carlos accounts list` gains a |
| + signed-in mode. |
| +- Direct/bucket mode is untouched: `ops` remains its genuine default. |
| + |
| +## Wire (no change needed) |
| + |
| +The endpoint already exists — discovered during implementation |
| +(2026-08-12): `GET /api/cli/apps` (handleAPIApps, live since main 5856dc5, |
| +2026-08-03, deployed on both production consoles) answers with the |
| +caller's own live memberships: |
| + |
| + → 200 {"accounts": [{"sqid": "bdk", "name": "tom@keymail.dev", |
| + "role": "owner", "apps": […]}, …]} |
| + |
| +- `Store.AccountsFor`-backed: deleted accounts excluded, creation order |
| + (the console switcher's order) — the chooser's first entry is the |
| + personal account minted at sign-up. Leak-tested |
| + (TestAPIAppsListsMemberships). |
| +- The CLI decodes only `name`/`sqid` (plus `role` for `accounts list`). |
| + **No new endpoint and no console deploy required**; the 404 fallback |
| + below covers only consoles older than 2026-08-03. |
| + |
| +## CLI resolution (API mode) |
| + |
| +The `-account` flag default changes from `"ops"` to `""` on every verb that |
| +takes it (the `accountFlags` helper and every hand-rolled API-verb pair — |
| +`apps create`/`delete`/`restore`/`place` and any sibling found in the |
| +implementation sweep). |
| +Direct mode is unaffected: `resolveAccount` already answers `""` with |
| +`release.OpsAccount`. |
| + |
| +In API mode an empty account resolves through one shared seam on the |
| +session, in order: |
| + |
| +1. **Explicit flags** (`-account-id`, then `-account`) — travel verbatim, |
| + exactly as today. |
| +2. **Config default:** the `account` key of the nearest `.carlos/config` |
| + (same upward walk as the console default, then global). Honored **only |
| + when that same file's `console` matches the session's console** — the |
| + pair is one fact; `-console` to a different stack must not drag the |
| + other stack's account along. A consoleless `{"account": …}` is |
| + deliberately NOT a wildcard (tightened in review, 2026-08-12): the |
| + committed `console` key is nearly inert as repo input — no credential, |
| + no request — but an account applying to every console would let a |
| + checked-in file silently redirect ships between stacks the caller |
| + really is a member of. The chooser always writes the pair, so only a |
| + hand-written file can even be consoleless. |
| +3. **Memberships** via `GET /api/cli/apps`: |
| + - exactly one → use its **sqid, exact-matched** (`?exact=sqid`), and |
| + print a muted `account: <name> (<sqid>)` line under the "using …" |
| + banner so what was guessed is always visible; |
| + - several + TTY (stdin AND stdout are char devices) → numbered chooser, |
| + then the save prompt; |
| + - several + non-TTY → error: |
| + `you belong to several accounts — say which with -account: tom@keymail.dev (bdk), acme (bce)`; |
| + - zero (unreachable in practice — sign-in mints a personal account) → |
| + error naming the console. |
| +4. **Old console** (endpoint 404s) → send `ops`, byte-for-byte today's |
| + behavior — but SAID: a muted |
| + `account: ops (<console> did not answer with your memberships)` line |
| + prints (review, 2026-08-12: a silent ops followed by the membership 404 |
| + is the exact riddle #157 was filed about, and any 404-shaped thing in |
| + front of a console — proxy, mis-stamped route — lands here too). |
| + |
| +The resolution happens once per invocation, before the first request that |
| +needs an account path; verbs that never touch an account path never call it. |
| + |
| +## Chooser UX |
| + |
| + you belong to several accounts on https://console.carloku.com: |
| + 1. tom@keymail.dev (bdk) |
| + 2. acme (bce) |
| + account [1-2]: |
| + |
| + save acme as this directory's default account (.carlos/config)? [y/N] |
| + |
| +- Pick by number; anything else (including empty) re-prompts once, then |
| + errors with the non-TTY message. EOF (^D) errors the same way. |
| +- `y` writes `{"console": <session console>, "account": <name>}` to |
| + `./.carlos/config` **in the working directory** — same placement rule as |
| + `auth default -project` ("run it where you want the file"). Any other |
| + answer saves nothing. |
| +- **From `$HOME` the save is not offered at all** (review, 2026-08-12): |
| + there `./.carlos/config` IS `~/.carlos/config`, and a y/N buried inside |
| + a ship must not silently repoint the machine-wide default console. |
| +- **A failed save warns and continues** (review, 2026-08-12): the human |
| + already answered and the account is known — a read-only checkout must |
| + not turn bookkeeping into a dead ship. |
| +- Resolution is memoized on the session: however many times a verb asks |
| + (`secrets set` asks for the seal key and again for the write), the |
| + chooser runs at most once and both answers are the same account. |
| +- The saved value is the account **name** (readable in a committed file); |
| + it resolves server-side against memberships like any `-account` value. |
| +- Single-account users never see either prompt. |
| + |
| +## Config schema |
| + |
| +`cliConfig` gains `Account string \`json:"account"\``. Two behavior changes |
| +to the file handling: |
| + |
| +- **Rewrites preserve unknown fields** from now on: load into a raw map, |
| + set the keys being written, marshal back. Today's "unknown fields are |
| + dropped on rewrite" makes every future key lossy against old binaries; |
| + this stops the bleeding for keys added after this one. |
| +- **Accepted wrinkle:** binaries older than this change still drop the |
| + `account` key when they rewrite the file (`auth default <url>`). It is a |
| + default, not data — recoverable by re-choosing. |
| + |
| +A config `account` that no longer matches any membership fails server-side |
| +with the membership 404 — the error is not rewritten, but the muted |
| +`account: …` line above it names what the config supplied and from which |
| +file, so the fix is evident. |
| + |
| +## Discovery verbs |
| + |
| +- **`carlos accounts list`**: signed-in mode calls the endpoint and prints |
| + a NAME/SQID/ROLE table (role rides free on the existing wire shape). The |
| + registry form stays for boxes; the split keys off **registry-file |
| + existence, not `resolveMode`** — the verb must keep working on a box |
| + with a registry and neither bucket env nor credentials. `-console` skips |
| + the registry even where one exists. |
| +- **`carlos auth whoami`**: after identity, fetches the endpoint and prints |
| + `accounts: tom@keymail.dev (bdk), acme (bce)`. Degrades **silently** on |
| + 404/error — whoami's job is identity; the membership line is a bonus, and |
| + an old console must not make whoami look broken. |
| + |
| +## Security |
| + |
| +- The endpoint returns only the caller's own memberships, resolved from the |
| + token's address — no account enumeration surface. |
| +- The chooser writes only `./.carlos/config` (0755 dir, non-secret file, |
| + committed on purpose) and only after an explicit `y`. |
| +- The membership 404's byte-identical opacity for wrong/foreign accounts is |
| + unchanged. |
| + |
| +## Testing |
| + |
| +- Console: `authcli_test` — own memberships only, deleted excluded, sorted, |
| + 401 bare/wrong token. |
| +- CLI resolution: table test against httptest consoles — one/several/zero |
| + memberships, endpoint-404 fallback to `ops`, config-account honored, |
| + config console-mismatch ignored, explicit flags bypass everything. |
| +- Chooser: unit test with injected reader/writer — pick, re-prompt, EOF, |
| + save-yes writes the file, save-no doesn't. TTY gating itself is covered |
| + by the non-TTY refusal path in the resolution tests. |
| +- Config: `account` key round-trips; rewrite preserves unknown fields; |
| + console-only files still parse ("" account = no default at this level). |
| +- Existing `carlosTestEnv` rules apply to anything that shells the binary. |
| + |
| +## Out of scope |
| + |
| +- #81 (apps create echoes sqid where a human wants the name), #83 (promote |
| + version picker), #79 (ship-before-claim recovery). |
| +- A `carlos accounts default` verb — the chooser's save prompt plus editing |
| + the file covers it. |
| +- Any change to box-side/direct-mode account handling. |
diff --git a/fleets.go b/fleets.go| index 8dffa5b06..efaa88cda 100644 |
| --- a/fleets.go |
| +++ b/fleets.go |
| @@ -76,7 +76,8 @@ const fleetsUsage = `usage: carlos fleets <create|add-box|rotate-token|tag-box|d |
| list every fleet the account owns, with each box's label, |
| status and tags. |
| - -account <sqid|name> account the fleet belongs to (default: ops) — |
| + -account <sqid|name> account the fleet belongs to (default: your only |
| + account, or the configured/chosen one) — |
| resolved by the console against your own |
| memberships. |
| -account-id <sqid> account sqid, matched as a sqid ONLY — supersedes |
| @@ -155,7 +156,7 @@ func validBoxLabel(label string) error { |
| 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") |
| + acctRaw := fs.String("account", "", "account (sqid or name) to own the fleet (default: your only account, or the configured/chosen one)") |
| 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) |
| @@ -170,8 +171,8 @@ func runFleetsCreate(args []string) { |
| bucketV := strings.TrimSpace(*bucket) |
| sess := fleetsSignedInSession(*consoleFlag) |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| f, err := sess.createFleet(context.Background(), acct, name, bucketV) |
| if err != nil { |
| log.Fatalf("fleets create: %v", err) |
| @@ -182,7 +183,7 @@ func runFleetsCreate(args []string) { |
| 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") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)") |
| 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) |
| @@ -199,8 +200,8 @@ func runFleetsAddBox(args []string) { |
| } |
| sess := fleetsSignedInSession(*consoleFlag) |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| token, err := sess.addFleetBox(context.Background(), acct, fleet, label) |
| if err != nil { |
| log.Fatalf("fleets add-box: %v", err) |
| @@ -210,7 +211,7 @@ func runFleetsAddBox(args []string) { |
| 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") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)") |
| 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) |
| @@ -227,8 +228,8 @@ func runFleetsRotateToken(args []string) { |
| } |
| sess := fleetsSignedInSession(*consoleFlag) |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| token, err := sess.rotateFleetBoxToken(context.Background(), acct, fleet, label) |
| if err != nil { |
| log.Fatalf("fleets rotate-token: %v", err) |
| @@ -273,7 +274,7 @@ func printFleetToken(fleet, label, token string, rotated bool) { |
| // "named, empty", not the value in *tags. |
| func runFleetsTagBox(args []string) { |
| fs := flag.NewFlagSet("fleets tag-box", flag.ExitOnError) |
| - acctRaw := fs.String("account", "ops", "account (sqid or name) the fleet belongs to") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| consoleFlag := fs.String("console", "", "console to tag through (default: the only logged-in one)") |
| tags := fs.String("tags", "", `comma-separated tags, full replace ("" clears every tag) — required`) |
| @@ -305,8 +306,8 @@ func runFleetsTagBox(args []string) { |
| } |
| sess := fleetsSignedInSession(*consoleFlag) |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| if err := sess.tagFleetBox(context.Background(), acct, fleet, label, tagsV); err != nil { |
| log.Fatalf("fleets tag-box: %v", err) |
| } |
| @@ -319,7 +320,7 @@ func runFleetsTagBox(args []string) { |
| 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") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the fleet belongs to (default: your only account, or the configured/chosen one)") |
| 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) |
| @@ -336,8 +337,8 @@ func runFleetsDetachBox(args []string) { |
| } |
| sess := fleetsSignedInSession(*consoleFlag) |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| if err := sess.detachFleetBox(context.Background(), acct, fleet, label); err != nil { |
| log.Fatalf("fleets detach-box: %v", err) |
| } |
| @@ -346,14 +347,14 @@ func runFleetsDetachBox(args []string) { |
| func runFleetsList(args []string) { |
| fs := flag.NewFlagSet("fleets ls", flag.ExitOnError) |
| - acctRaw := fs.String("account", "ops", "account (sqid or name) to list fleets for") |
| + acctRaw := fs.String("account", "", "account (sqid or name) to list fleets for (default: your only account, or the configured/chosen one)") |
| 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) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| fleets, err := sess.listFleets(context.Background(), acct) |
| if err != nil { |
| log.Fatalf("fleets ls: %v", err) |
diff --git a/instances_cli.go b/instances_cli.go| index 04b37bca0..cf5c6977a 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -118,8 +118,8 @@ func runInstancesEnable(args []string) { |
| } |
| seedReq = &argv |
| } |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| m, err := sess.instancesEnable(context.Background(), acct, *app, channelReq, seedReq) |
| if err != nil { |
| log.Fatalf("instances enable: %v", err) |
| @@ -241,7 +241,7 @@ func runInstancesCreate(args []string) { |
| region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list)") |
| channel := fs.String("channel", "", "release channel the instance follows (default: the manifest's, then stable)") |
| consoleFlag := fs.String("console", "", "console to create through (default: the only logged-in one)") |
| - acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| fs.Parse(args) |
| if *app == "" || *host == "" { |
| @@ -249,7 +249,7 @@ func runInstancesCreate(args []string) { |
| } |
| sess := requireAPISession("create", *consoleFlag) |
| printUsing(stdout, sess) |
| - rec, err := sess.instanceCreate(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, |
| + rec, err := sess.instanceCreate(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, |
| strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel)) |
| if err != nil { |
| log.Fatalf("instances create: %v", err) |
| @@ -264,14 +264,14 @@ func runInstancesList(args []string) { |
| match := fs.String("match", "", "only hosts containing this substring") |
| health := fs.String("health", "", "only this state: running, serving, setting-up, asleep, no-readings, not-responding, failed") |
| consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)") |
| - acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| fs.Parse(args) |
| if *app == "" { |
| log.Fatal(instancesRecordsUsage) |
| } |
| sess := requireAPISession("list", *consoleFlag) |
| - recs, truncated, total, err := sess.instanceList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, *match, *health) |
| + recs, truncated, total, err := sess.instanceList(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, *match, *health) |
| if err != nil { |
| log.Fatalf("instances list: %v", err) |
| } |
| @@ -304,7 +304,7 @@ func runInstancesDelete(args []string) { |
| app := fs.String("app", "", "app the instance belongs to") |
| host := fs.String("host", "", "instance host whose record to retire") |
| consoleFlag := fs.String("console", "", "console to delete through (default: the only logged-in one)") |
| - acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| fs.Parse(args) |
| if *app == "" || *host == "" { |
| @@ -312,7 +312,7 @@ func runInstancesDelete(args []string) { |
| } |
| sess := requireAPISession("delete", *consoleFlag) |
| printUsing(stdout, sess) |
| - if err := sess.instanceDelete(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, strings.TrimSpace(*host)); err != nil { |
| + if err := sess.instanceDelete(context.Background(), resolvedAccount(sess, acctRaw, acctIDRaw), *app, strings.TrimSpace(*host)); err != nil { |
| log.Fatalf("instances delete: %v", err) |
| } |
| fmt.Fprintf(stdout, "%s %s — its route retires on the next reconcile pass; parked data stays\n", |
diff --git a/logs.go b/logs.go| index 558f7fdb0..dd267d1db 100644 |
| --- a/logs.go |
| +++ b/logs.go |
| @@ -67,7 +67,7 @@ func runLogs(args []string) { |
| n := fs.Int("n", 0, "at most this many lines (default 1000, max 5000)") |
| follow := fs.Bool("f", false, "follow: poll for new lines until interrupted") |
| consoleFlag := fs.String("console", "", "console to read through (default: the only logged-in one)") |
| - acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| fs.Usage = func() { fmt.Fprint(os.Stderr, logsUsage) } |
| fs.Parse(args) |
| @@ -92,7 +92,7 @@ func runLogs(args []string) { |
| } |
| sess := requireAPISession("logs", *consoleFlag) |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| ctx := context.Background() |
| res, err := sess.logsGet(ctx, acct, *app, q) |
| if err != nil { |
diff --git a/restart.go b/restart.go| index 25c9e5a31..c9d628b9c 100644 |
| --- a/restart.go |
| +++ b/restart.go |
| @@ -38,8 +38,8 @@ func runRestart(args []string) { |
| } |
| sess := requireRestartAPISession(*consoleFlag) |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| res, err := sess.restart(context.Background(), acct, name) |
| if err != nil { |
| log.Fatalf("restart: %v", err) |
diff --git a/retention.go b/retention.go| index f031cded9..f4de48820 100644 |
| --- a/retention.go |
| +++ b/retention.go |
| @@ -51,8 +51,8 @@ func runReleasesRetention(args []string) { |
| } |
| sess := requireRetentionAPISession(*consoleFlag) |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| ctx := context.Background() |
| switch { |
| case *off: |
diff --git a/store.go b/store.go| index 3192348bb..05075cefe 100644 |
| --- a/store.go |
| +++ b/store.go |
| @@ -60,7 +60,8 @@ const storeUsage = `usage: carlos store <create|status|grant|rotate> [flags] |
| until -finish deletes it. A member verb: spends |
| nothing new, so there is no operator gate here. |
| - -account <sqid|name> account the app belongs to (default: ops) — |
| + -account <sqid|name> account the app belongs to (default: your only |
| + account, or the configured/chosen one) — |
| resolved by the console against your own |
| memberships; signed-in mode only. |
| -account-id <sqid> account sqid, matched as a sqid ONLY. |
| @@ -142,8 +143,8 @@ func runStoreCreate(args []string) { |
| } |
| sess := requireStoreAPISession(*consoleFlag) |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| res, err := sess.storeCreate(context.Background(), acct, name, strings.TrimSpace(*envPrefix)) |
| if err != nil { |
| log.Fatalf("store create: %v", err) |
| @@ -166,8 +167,8 @@ func runStoreStatus(args []string) { |
| } |
| sess := requireStoreAPISession(*consoleFlag) |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| res, err := sess.storeGet(context.Background(), acct, name) |
| if err != nil { |
| log.Fatalf("store status: %v", err) |
| @@ -191,8 +192,8 @@ func runStoreGrant(args []string) { |
| } |
| sess := requireStoreAPISession(*consoleFlag) |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| res, err := sess.storeGrant(context.Background(), acct, name, strings.TrimSpace(*bucket)) |
| if err != nil { |
| log.Fatalf("store grant: %v", err) |
| @@ -216,8 +217,8 @@ func runStoreRotate(args []string) { |
| } |
| sess := requireStoreAPISession(*consoleFlag) |
| - acct := apiAccount(acctRaw, acctIDRaw) |
| printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| res, err := sess.storeRotate(context.Background(), acct, name, *finish) |
| if err != nil { |
| log.Fatalf("store rotate: %v", err) |