cli: interactive account chooser
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+150
−0
account_choose.go+73 −0account_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) |
| + } |
| + } |
| +} |