Skip to main content

ytcli/cli/
wizard.rs

1//! The interactive half of `auth login`.
2//!
3//! Modelled on `glab auth login`: one question at a time, with the token entered
4//! as a password so it never reaches the terminal, the scrollback or the shell
5//! history. Everything the wizard collects can also be passed as flags, which is
6//! what CI and scripts use; the wizard fills in only what was not given.
7//!
8//! Prompts go to stderr. A wizard that wrote to stdout would corrupt the one
9//! machine-readable line the command emits.
10
11use std::io::Write as _;
12
13use dialoguer::theme::ColorfulTheme;
14use dialoguer::{Confirm, Input, Password, Select};
15
16use crate::cli::guidance;
17use crate::config::OrgKind;
18use crate::exit::ExitCode;
19
20/// What the wizard could not resolve on its own.
21#[derive(Debug, thiserror::Error)]
22pub enum WizardError {
23    #[error("cancelled")]
24    Cancelled,
25    #[error("could not read your answer")]
26    Io(#[from] dialoguer::Error),
27}
28
29impl WizardError {
30    #[must_use]
31    pub fn exit_code(&self) -> ExitCode {
32        match self {
33            Self::Cancelled => ExitCode::ConfirmationRequired,
34            Self::Io(_) => ExitCode::Failure,
35        }
36    }
37}
38
39fn theme() -> ColorfulTheme {
40    ColorfulTheme::default()
41}
42
43/// Is anyone actually there to answer?
44#[must_use]
45pub fn is_interactive() -> bool {
46    use std::io::IsTerminal;
47    std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
48}
49
50/// Print the whole of `auth login --help`'s guidance, once, before anything is
51/// asked for.
52///
53/// It used to appear a block at a time, above the prompt each block belonged
54/// to. That is too late: by the time the token prompt asks for a token, the
55/// person has already gone looking for one. Both blocks up front means the
56/// procedure can be followed from the top, in one place, without leaving the
57/// command — and it is the same text `--help` prints, so neither can drift into
58/// being the real instructions while the other rots.
59pub fn introduce() {
60    let mut err = anstream::stderr();
61    let _ = writeln!(err, "\n{}", guidance::full());
62}
63
64/// Account name: which identity this token belongs to.
65pub fn account(existing: &[String]) -> Result<String, WizardError> {
66    let mut err = anstream::stderr();
67    if existing.is_empty() {
68        let _ = writeln!(
69            err,
70            "{}",
71            guidance::block(
72                "An account holds one token. A **profile** is an organisation seen through an account — so one account can serve several organisations."
73            )
74        );
75    } else {
76        let _ = writeln!(err, "\nAccounts you already have: {}", existing.join(", "));
77    }
78
79    let name: String = Input::with_theme(&theme())
80        .with_prompt("Account name")
81        .default("default".to_owned())
82        .interact_text()?;
83
84    Ok(name.trim().to_owned())
85}
86
87/// Sign in through the browser, or paste a token?
88///
89/// The browser comes first and is the default: nothing to register, nothing to
90/// copy, and the token never passes through the clipboard.
91pub fn sign_in_in_browser() -> Result<bool, WizardError> {
92    let choice = Select::with_theme(&theme())
93        .with_prompt("How do you want to sign in?")
94        .default(0)
95        .items([
96            "In the browser, with a short code — nothing to set up",
97            "Paste an OAuth token",
98        ])
99        .interact()?;
100    Ok(choice == 0)
101}
102
103/// Wait for Enter.
104///
105/// A browser that opens the moment the code is printed covers the terminal
106/// before anyone has read the code off it.
107pub fn press_enter(prompt: &str) -> Result<(), WizardError> {
108    let mut err = anstream::stderr();
109    let _ = write!(err, "{prompt}");
110    let _ = err.flush();
111
112    let mut line = String::new();
113    std::io::stdin()
114        .read_line(&mut line)
115        .map_err(|error| WizardError::Io(dialoguer::Error::IO(error)))?;
116    Ok(())
117}
118
119/// The token itself, entered as a password.
120///
121/// `Password` is the whole point of doing this interactively: a token typed as
122/// an argument is visible in `ps` and lands in shell history, and one echoed at
123/// a prompt stays in the scrollback.
124pub fn token(account: &str) -> Result<String, WizardError> {
125    let token = Password::with_theme(&theme())
126        .with_prompt(format!("Paste the OAuth token for `{account}`"))
127        .interact()?;
128
129    let token = token.trim().to_owned();
130    if token.is_empty() {
131        return Err(WizardError::Cancelled);
132    }
133    Ok(token)
134}
135
136/// Which organisation, and which flavour it is.
137///
138/// "Detect" is the first option and the default: the two flavours use different
139/// headers, and picking the wrong one produces a 403 that reads like a rights
140/// problem. Letting the tool try both costs one request.
141pub fn organisation() -> Result<(String, Option<OrgKind>), WizardError> {
142    let id: String = Input::with_theme(&theme())
143        .with_prompt("Organisation id")
144        .interact_text()?;
145
146    let choice = Select::with_theme(&theme())
147        .with_prompt("Organisation kind")
148        .default(0)
149        .items([
150            "Detect it for me",
151            "Yandex Cloud Organization (X-Cloud-Org-Id)",
152            "Yandex 360 for Business (X-Org-Id)",
153        ])
154        .interact()?;
155
156    let kind = match choice {
157        1 => Some(OrgKind::Cloud),
158        2 => Some(OrgKind::Yandex360),
159        _ => None,
160    };
161
162    Ok((id.trim().to_owned(), kind))
163}
164
165/// Profile name, defaulting to the account name.
166pub fn profile(default: &str) -> Result<String, WizardError> {
167    let name: String = Input::with_theme(&theme())
168        .with_prompt("Profile name")
169        .default(default.to_owned())
170        .interact_text()?;
171
172    Ok(name.trim().to_owned())
173}
174
175/// Default queue, chosen from the ones this token can actually see.
176///
177/// The list is fetched after the token is verified, so this is a pick rather
178/// than a spelling test — which is the difference between a wizard and a form.
179pub fn queue(available: &[String]) -> Result<Option<String>, WizardError> {
180    if available.is_empty() {
181        let typed: String = Input::with_theme(&theme())
182            .with_prompt("Default queue (optional)")
183            .allow_empty(true)
184            .interact_text()?;
185        let typed = typed.trim();
186        return Ok((!typed.is_empty()).then(|| typed.to_owned()));
187    }
188
189    let mut items: Vec<String> = vec!["(none)".to_owned()];
190    items.extend(available.iter().cloned());
191
192    let choice = Select::with_theme(&theme())
193        .with_prompt("Default queue for this profile")
194        .default(0)
195        .items(&items)
196        .interact()?;
197
198    Ok(if choice == 0 {
199        None
200    } else {
201        items.get(choice).cloned()
202    })
203}
204
205/// The note that says which organisation this profile is.
206///
207/// Optional, and empty means "leave it as it was": a re-login is about the
208/// token, and clearing someone's note because they pressed Enter would be a
209/// surprise. `auth edit --clear-description` is the way to remove one.
210pub fn description(current: Option<&str>) -> Result<Option<String>, WizardError> {
211    let typed: String = Input::with_theme(&theme())
212        .with_prompt("What is this organisation? (optional)")
213        .with_initial_text(current.unwrap_or_default())
214        .allow_empty(true)
215        .interact_text()?;
216
217    let typed = typed.trim();
218    Ok((!typed.is_empty()).then(|| typed.to_owned()))
219}
220
221/// Should this profile be the one a bare command uses?
222pub fn make_default(profile: &str, current: Option<&str>) -> Result<bool, WizardError> {
223    let Some(current) = current else {
224        // Nothing to displace, and a config with no default is unusable.
225        return Ok(true);
226    };
227    if current == profile {
228        return Ok(true);
229    }
230
231    Ok(Confirm::with_theme(&theme())
232        .with_prompt(format!(
233            "Make `{profile}` the default profile? (currently `{current}`)"
234        ))
235        .default(false)
236        .interact()?)
237}