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/// The token itself, entered as a password.
88///
89/// `Password` is the whole point of doing this interactively: a token typed as
90/// an argument is visible in `ps` and lands in shell history, and one echoed at
91/// a prompt stays in the scrollback.
92pub fn token(account: &str) -> Result<String, WizardError> {
93    let token = Password::with_theme(&theme())
94        .with_prompt(format!("Paste the OAuth token for `{account}`"))
95        .interact()?;
96
97    let token = token.trim().to_owned();
98    if token.is_empty() {
99        return Err(WizardError::Cancelled);
100    }
101    Ok(token)
102}
103
104/// Which organisation, and which flavour it is.
105///
106/// "Detect" is the first option and the default: the two flavours use different
107/// headers, and picking the wrong one produces a 403 that reads like a rights
108/// problem. Letting the tool try both costs one request.
109pub fn organisation() -> Result<(String, Option<OrgKind>), WizardError> {
110    let id: String = Input::with_theme(&theme())
111        .with_prompt("Organisation id")
112        .interact_text()?;
113
114    let choice = Select::with_theme(&theme())
115        .with_prompt("Organisation kind")
116        .default(0)
117        .items([
118            "Detect it for me",
119            "Yandex Cloud Organization (X-Cloud-Org-Id)",
120            "Yandex 360 for Business (X-Org-Id)",
121        ])
122        .interact()?;
123
124    let kind = match choice {
125        1 => Some(OrgKind::Cloud),
126        2 => Some(OrgKind::Yandex360),
127        _ => None,
128    };
129
130    Ok((id.trim().to_owned(), kind))
131}
132
133/// Profile name, defaulting to the account name.
134pub fn profile(default: &str) -> Result<String, WizardError> {
135    let name: String = Input::with_theme(&theme())
136        .with_prompt("Profile name")
137        .default(default.to_owned())
138        .interact_text()?;
139
140    Ok(name.trim().to_owned())
141}
142
143/// Default queue, chosen from the ones this token can actually see.
144///
145/// The list is fetched after the token is verified, so this is a pick rather
146/// than a spelling test — which is the difference between a wizard and a form.
147pub fn queue(available: &[String]) -> Result<Option<String>, WizardError> {
148    if available.is_empty() {
149        let typed: String = Input::with_theme(&theme())
150            .with_prompt("Default queue (optional)")
151            .allow_empty(true)
152            .interact_text()?;
153        let typed = typed.trim();
154        return Ok((!typed.is_empty()).then(|| typed.to_owned()));
155    }
156
157    let mut items: Vec<String> = vec!["(none)".to_owned()];
158    items.extend(available.iter().cloned());
159
160    let choice = Select::with_theme(&theme())
161        .with_prompt("Default queue for this profile")
162        .default(0)
163        .items(&items)
164        .interact()?;
165
166    Ok(if choice == 0 {
167        None
168    } else {
169        items.get(choice).cloned()
170    })
171}
172
173/// Should this profile be the one a bare command uses?
174pub fn make_default(profile: &str, current: Option<&str>) -> Result<bool, WizardError> {
175    let Some(current) = current else {
176        // Nothing to displace, and a config with no default is unusable.
177        return Ok(true);
178    };
179    if current == profile {
180        return Ok(true);
181    }
182
183    Ok(Confirm::with_theme(&theme())
184        .with_prompt(format!(
185            "Make `{profile}` the default profile? (currently `{current}`)"
186        ))
187        .default(false)
188        .interact()?)
189}