| 1 | // The interactive account chooser: what an omitted --account means when the |
| 2 | // caller belongs to several accounts and a human is at the terminal. The |
| 3 | // non-interactive shape of the same question is severalAccountsErr — a |
| 4 | // script cannot answer a prompt, and refusing loudly beats hanging; the |
| 5 | // refusal doubles as membership discovery, since it names every account. |
| 6 | package main |
| 7 | |
| 8 | import ( |
| 9 | "bufio" |
| 10 | "fmt" |
| 11 | "io" |
| 12 | "os" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | ) |
| 16 | |
| 17 | // apiAccountEntry is one row of GET /api/cli/apps's "accounts" list |
| 18 | // (internal/console handleAPIApps) — only the fields this client reads. |
| 19 | // The endpoint's order is the store's creation order, so entry 1 is the |
| 20 | // personal account minted at sign-up. |
| 21 | type apiAccountEntry struct { |
| 22 | Name string `json:"name"` |
| 23 | Sqid string `json:"sqid"` |
| 24 | Role string `json:"role"` |
| 25 | // Apps rides the same response (handleAPIApps) — resolvedApp reads it, |
| 26 | // so account AND app resolution share one fetch per invocation. |
| 27 | Apps []string `json:"apps"` |
| 28 | } |
| 29 | |
| 30 | // severalAccountsErr refuses an ambiguous omitted --account, naming every |
| 31 | // membership so the refusal itself answers "what could I have said". |
| 32 | func severalAccountsErr(accts []apiAccountEntry) error { |
| 33 | names := make([]string, 0, len(accts)) |
| 34 | for _, a := range accts { |
| 35 | names = append(names, fmt.Sprintf("%s (%s)", a.Name, a.Sqid)) |
| 36 | } |
| 37 | return fmt.Errorf("you belong to several accounts — say which with --account: %s", |
| 38 | strings.Join(names, ", ")) |
| 39 | } |
| 40 | |
| 41 | // interactiveTerminal reports whether a prompt has a human on both ends. |
| 42 | // Stdin AND stdout must be terminals: a redirected stdout means the output |
| 43 | // is for a parser, and a redirected stdin has no one to answer. A var, not |
| 44 | // a func: a global that reads real file descriptors is untestable in both |
| 45 | // directions (under `go test` stdin is /dev/null — a char device — and only |
| 46 | // the piped stdout saves the check), so tests set it explicitly. |
| 47 | var interactiveTerminal = func() bool { |
| 48 | return charDevice(os.Stdin) && charDevice(os.Stdout) |
| 49 | } |
| 50 | |
| 51 | // chooserIn is where chooseAccount reads its answers — os.Stdin, swapped by |
| 52 | // tests for the same reason interactiveTerminal is a var. |
| 53 | var chooserIn io.Reader = os.Stdin |
| 54 | |
| 55 | // chooserSc is the ONE scanner over chooserIn, rebound when a test swaps |
| 56 | // the reader. One scanner, not one per prompt: a Scanner buffers ahead, so |
| 57 | // a second Scanner on the same reader would find the bytes the first one |
| 58 | // already swallowed — the second answer in "y\ny\n" simply vanishes. |
| 59 | var chooserSc *bufio.Scanner |
| 60 | var chooserScFor io.Reader |
| 61 | |
| 62 | func chooserScanner() *bufio.Scanner { |
| 63 | if chooserSc == nil || chooserScFor != chooserIn { |
| 64 | chooserSc = bufio.NewScanner(chooserIn) |
| 65 | chooserScFor = chooserIn |
| 66 | } |
| 67 | return chooserSc |
| 68 | } |
| 69 | |
| 70 | // promptLine prints one prompt and reads one trimmed line; EOF answers "". |
| 71 | func promptLine(out io.Writer, prompt string) string { |
| 72 | fmt.Fprint(out, prompt) |
| 73 | sc := chooserScanner() |
| 74 | if !sc.Scan() { |
| 75 | return "" |
| 76 | } |
| 77 | return strings.TrimSpace(sc.Text()) |
| 78 | } |
| 79 | |
| 80 | // promptYes asks a y/N question; only y/Y is yes, EOF and everything else |
| 81 | // is no. |
| 82 | func promptYes(out io.Writer, prompt string) bool { |
| 83 | return strings.EqualFold(promptLine(out, prompt), "y") |
| 84 | } |
| 85 | |
| 86 | // chooseOne asks which of items an omitted flag means: intro, a numbered |
| 87 | // list, `<noun> [1-N]:`. A junk answer re-prompts once; two misses or EOF |
| 88 | // return refuse — the same non-interactive refusal the caller would have |
| 89 | // used without a terminal, so both paths error identically. |
| 90 | func chooseOne(in io.Reader, out io.Writer, intro, noun string, items []string, refuse error) (string, error) { |
| 91 | fmt.Fprintln(out, intro) |
| 92 | for i, it := range items { |
| 93 | fmt.Fprintf(out, " %d. %s\n", i+1, it) |
| 94 | } |
| 95 | sc := bufio.NewScanner(in) |
| 96 | if in == chooserIn { |
| 97 | sc = chooserScanner() |
| 98 | } |
| 99 | for attempt := 0; attempt < 2; attempt++ { |
| 100 | fmt.Fprintf(out, "%s [1-%d]: ", noun, len(items)) |
| 101 | if !sc.Scan() { |
| 102 | return "", refuse |
| 103 | } |
| 104 | if n, err := strconv.Atoi(strings.TrimSpace(sc.Text())); err == nil && n >= 1 && n <= len(items) { |
| 105 | return items[n-1], nil |
| 106 | } |
| 107 | } |
| 108 | return "", refuse |
| 109 | } |
| 110 | |
| 111 | // severalAppsErr refuses an ambiguous omitted --app, naming every app so |
| 112 | // the refusal itself answers "what could I have said" — severalAccountsErr |
| 113 | // for the sibling flag. |
| 114 | func severalAppsErr(account string, apps []string) error { |
| 115 | return fmt.Errorf("account %s has several apps — say which with --app: %s", |
| 116 | account, strings.Join(apps, ", ")) |
| 117 | } |
| 118 | |
| 119 | // chooseAccount asks which account an omitted --account means, then — when |
| 120 | // saveDest names a file — whether to remember the answer there (the caller |
| 121 | // writes the config; this function only asks). An empty saveDest skips the |
| 122 | // save prompt entirely: from $HOME the "project" file would be the global |
| 123 | // config, and a y/N inside a ship must not carry that weight. A junk answer |
| 124 | // re-prompts once; two misses, or EOF at the pick, fall back to the |
| 125 | // non-interactive refusal so the caller's error path is identical either |
| 126 | // way. EOF or anything but y/Y at the save prompt means no — the pick |
| 127 | // itself stands. |
| 128 | func chooseAccount(in io.Reader, out io.Writer, console string, accts []apiAccountEntry, saveDest string) (apiAccountEntry, bool, error) { |
| 129 | fmt.Fprintf(out, "you belong to several accounts on %s:\n", console) |
| 130 | for i, a := range accts { |
| 131 | fmt.Fprintf(out, " %d. %s (%s)\n", i+1, a.Name, a.Sqid) |
| 132 | } |
| 133 | sc := bufio.NewScanner(in) |
| 134 | if in == chooserIn { |
| 135 | // The process-wide input shares the process-wide scanner — a private |
| 136 | // one here would buffer past the chooser's answers and starve any |
| 137 | // later promptLine (see chooserScanner). |
| 138 | sc = chooserScanner() |
| 139 | } |
| 140 | var picked *apiAccountEntry |
| 141 | for attempt := 0; attempt < 2 && picked == nil; attempt++ { |
| 142 | fmt.Fprintf(out, "account [1-%d]: ", len(accts)) |
| 143 | if !sc.Scan() { |
| 144 | return apiAccountEntry{}, false, severalAccountsErr(accts) |
| 145 | } |
| 146 | if n, err := strconv.Atoi(strings.TrimSpace(sc.Text())); err == nil && n >= 1 && n <= len(accts) { |
| 147 | picked = &accts[n-1] |
| 148 | } |
| 149 | } |
| 150 | if picked == nil { |
| 151 | return apiAccountEntry{}, false, severalAccountsErr(accts) |
| 152 | } |
| 153 | if saveDest == "" { |
| 154 | return *picked, false, nil |
| 155 | } |
| 156 | fmt.Fprintf(out, "save %s as this directory's default account (%s)? [y/N] ", picked.Name, saveDest) |
| 157 | save := sc.Scan() && strings.EqualFold(strings.TrimSpace(sc.Text()), "y") |
| 158 | return *picked, save, nil |
| 159 | } |
| 160 | |