carlos / platform Public

cli: interactive account chooser

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 903edbdf87f650db465a8110d2f2a9ab19969dc0 parent e0eaab8
2 files changed, +150 −0
  • account_choose.go +73 −0
  • account_choose_test.go +77 −0
diff --git a/account_choose.go b/account_choose.go
new file mode 100644
index 000000000..04a95ce68
--- /dev/null
+++ b/account_choose.go
@@ -0,0 +1,73 @@
+// 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.
+func interactiveTerminal() bool {
+ return charDevice(os.Stdin) && charDevice(os.Stdout)
+}
+
+// chooseAccount asks which account an omitted -account means, then whether
+// to remember the answer as the project's default (the caller writes the
+// config; this function only asks). 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) (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)
+ }
+ fmt.Fprintf(out, "save %s as this project's default account? [y/N] ", picked.Name)
+ 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..3296a285e
--- /dev/null
+++ b/account_choose_test.go
@@ -0,0 +1,77 @@
+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)
+ 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 project's default account? [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)
+ if err != nil || picked.Sqid != "bdk" || !save {
+ t.Fatalf("pick 1 + save = %+v save=%v err=%v", picked, save, err)
+ }
+
+ // 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)
+ 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)
+ 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)
+ 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); err == nil || err.Error() != refusal {
+ t.Fatalf("input %q = %v, want the several-accounts refusal", input, err)
+ }
+ }
+}