carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
6package main
7
8import (
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.
21type 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".
32func 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.
47var 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.
53var 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.
59var chooserSc *bufio.Scanner
60var chooserScFor io.Reader
61
62func 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 "".
71func 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.
82func 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.
90func 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.
114func 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.
128func 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