// 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"` // Apps rides the same response (handleAPIApps) — resolvedApp reads it, // so account AND app resolution share one fetch per invocation. Apps []string `json:"apps"` } // 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 // chooserSc is the ONE scanner over chooserIn, rebound when a test swaps // the reader. One scanner, not one per prompt: a Scanner buffers ahead, so // a second Scanner on the same reader would find the bytes the first one // already swallowed — the second answer in "y\ny\n" simply vanishes. var chooserSc *bufio.Scanner var chooserScFor io.Reader func chooserScanner() *bufio.Scanner { if chooserSc == nil || chooserScFor != chooserIn { chooserSc = bufio.NewScanner(chooserIn) chooserScFor = chooserIn } return chooserSc } // promptLine prints one prompt and reads one trimmed line; EOF answers "". func promptLine(out io.Writer, prompt string) string { fmt.Fprint(out, prompt) sc := chooserScanner() if !sc.Scan() { return "" } return strings.TrimSpace(sc.Text()) } // promptYes asks a y/N question; only y/Y is yes, EOF and everything else // is no. func promptYes(out io.Writer, prompt string) bool { return strings.EqualFold(promptLine(out, prompt), "y") } // chooseOne asks which of items an omitted flag means: intro, a numbered // list, ` [1-N]:`. A junk answer re-prompts once; two misses or EOF // return refuse — the same non-interactive refusal the caller would have // used without a terminal, so both paths error identically. func chooseOne(in io.Reader, out io.Writer, intro, noun string, items []string, refuse error) (string, error) { fmt.Fprintln(out, intro) for i, it := range items { fmt.Fprintf(out, " %d. %s\n", i+1, it) } sc := bufio.NewScanner(in) if in == chooserIn { sc = chooserScanner() } for attempt := 0; attempt < 2; attempt++ { fmt.Fprintf(out, "%s [1-%d]: ", noun, len(items)) if !sc.Scan() { return "", refuse } if n, err := strconv.Atoi(strings.TrimSpace(sc.Text())); err == nil && n >= 1 && n <= len(items) { return items[n-1], nil } } return "", refuse } // severalAppsErr refuses an ambiguous omitted --app, naming every app so // the refusal itself answers "what could I have said" — severalAccountsErr // for the sibling flag. func severalAppsErr(account string, apps []string) error { return fmt.Errorf("account %s has several apps — say which with --app: %s", account, strings.Join(apps, ", ")) } // 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) if in == chooserIn { // The process-wide input shares the process-wide scanner — a private // one here would buffer past the chooser's answers and starve any // later promptLine (see chooserScanner). sc = chooserScanner() } 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 }