Skip to main content

ytcli/cli/
auth.rs

1//! Account and profile commands.
2//!
3//! `login` is the only command that touches a secret, and it reads it from a
4//! prompt or stdin — never from an argument, because arguments are visible in
5//! `ps` and in shell history. There is deliberately no command that prints a
6//! stored token.
7
8use std::fmt::Write as _;
9use std::io::Write as _;
10
11use clap::{Args, Subcommand};
12
13use crate::api::{Client, ClientConfig};
14use crate::cli::{Session, emit, guidance, report, wizard};
15use crate::config::{OrgKind, Profile, store};
16use crate::exit::ExitCode;
17use crate::render::style::{Painter, Palette};
18use crate::secrets;
19
20#[derive(Debug, Subcommand)]
21pub enum AuthCommand {
22    /// Store a token for an account, and set up a profile to use it with.
23    #[command(long_about = crate::cli::guidance::login_help())]
24    Login(LoginArgs),
25    /// Remove a stored token.
26    #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_LOGOUT))]
27    Logout {
28        #[arg(long, short = 'a')]
29        account: String,
30    },
31    /// List configured accounts and profiles.
32    #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_LIST))]
33    List,
34    /// Make a profile the default one.
35    #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_USE))]
36    Use {
37        /// Profile name, as `auth list` prints it.
38        profile: String,
39    },
40    /// Check every profile: who the token belongs to, and what it can see.
41    #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_STATUS))]
42    Status {
43        /// Identity only — skip the counts, and the requests behind them.
44        #[arg(long)]
45        brief: bool,
46        /// Check only the active profile instead of all of them.
47        #[arg(long)]
48        active_only: bool,
49    },
50}
51
52/// Arguments for `auth login`.
53///
54/// The token is deliberately absent: it is read from a prompt or from stdin,
55/// never from an argument, because arguments are visible in `ps` and land in
56/// shell history.
57#[derive(Debug, Args)]
58pub struct LoginArgs {
59    /// Account name to store the token under. Asked for when omitted.
60    #[arg(long, short = 'a')]
61    pub account: Option<String>,
62
63    /// Organisation id. Given this, login also writes a profile.
64    #[arg(long)]
65    pub org_id: Option<String>,
66
67    /// Which header carries the organisation id. Detected when omitted.
68    #[arg(long, value_enum)]
69    pub org_kind: Option<OrgKind>,
70
71    /// Profile name to create; defaults to the account name.
72    #[arg(long, short = 'p')]
73    pub profile: Option<String>,
74
75    /// Queue this profile assumes when a command needs one.
76    #[arg(long, short = 'q')]
77    pub queue: Option<String>,
78
79    /// Make this the default profile even if another one already is.
80    #[arg(long)]
81    pub default: bool,
82
83    /// Skip the check that the token and organisation actually work.
84    #[arg(long)]
85    pub no_verify: bool,
86}
87
88/// Run an auth subcommand.
89pub async fn run(command: &AuthCommand, session: &Session) -> ExitCode {
90    match command {
91        AuthCommand::Status { brief, active_only } => status(session, *brief, *active_only).await,
92        AuthCommand::Login(args) => login(args, session).await,
93        AuthCommand::Logout { account } => logout(account),
94        AuthCommand::List => list(session),
95        AuthCommand::Use { profile } => use_profile(session, profile),
96    }
97}
98
99/// Report on the configured profiles.
100///
101/// This is the command someone runs when something is wrong, so it answers the
102/// questions that actually get asked: which profile is in play and where that
103/// choice came from, whether the token works, who it belongs to, and what it can
104/// reach. Checking every profile rather than only the active one is deliberate —
105/// "it works with my other login" is the usual next question.
106///
107/// The counts cost a handful of requests per profile. That is fine for a
108/// diagnostic and wrong for a hot path, which is what `--brief` is for.
109async fn status(session: &Session, brief: bool, active_only: bool) -> ExitCode {
110    let mut out = anstream::stdout();
111    let mut err = anstream::stderr();
112    let paint = session.render.painter();
113
114    if session.config.profiles.is_empty() {
115        let _ = writeln!(err, "no profiles configured yet.\n");
116        let _ = writeln!(err, "{}", guidance::full());
117        let _ = writeln!(
118            err,
119            "Then: ytcli auth login --account <name> --org-id <id> [--queue <QUEUE>]"
120        );
121        return ExitCode::Auth;
122    }
123
124    report_sources(session, paint, &mut out);
125
126    let active = session
127        .resolved
128        .as_ref()
129        .map(|resolved| resolved.name.clone());
130    let mut active_failure = None;
131    let mut any_success = false;
132    let mut last_failure = None;
133    // Which profiles can see each queue key, so the ambiguity can be reported.
134    let mut queues_seen: std::collections::BTreeMap<String, Vec<String>> =
135        std::collections::BTreeMap::new();
136
137    for (name, profile) in &session.config.profiles {
138        let is_active = active.as_deref() == Some(name.as_str());
139        if active_only && !is_active {
140            continue;
141        }
142
143        let source = if is_active {
144            session
145                .resolved
146                .as_ref()
147                .map_or_else(String::new, |resolved| {
148                    format!(" (from {})", resolved.source)
149                })
150        } else {
151            String::new()
152        };
153        let marks = if is_active { "  [active]" } else { "" };
154
155        let _ = writeln!(
156            out,
157            "{} {}{}{}",
158            paint.paint("profile", Palette::label()),
159            paint.paint(name, Palette::key()),
160            paint.paint(&source, Palette::label()),
161            paint.paint(marks, Palette::ok()),
162        );
163        let _ = writeln!(
164            out,
165            "  {} {}   {} {} ({:?})   {} {}",
166            paint.paint("account:", Palette::label()),
167            profile.account,
168            paint.paint("org:", Palette::label()),
169            profile.org_id,
170            profile.org_kind,
171            paint.paint("queue:", Palette::label()),
172            profile.default_queue.as_deref().unwrap_or("-"),
173        );
174
175        let code = report_profile(
176            profile,
177            brief,
178            paint,
179            name,
180            &mut queues_seen,
181            &mut out,
182            &mut err,
183        )
184        .await;
185        if code == ExitCode::Success {
186            any_success = true;
187        } else {
188            last_failure = Some(code);
189            if is_active {
190                active_failure = Some(code);
191            }
192        }
193    }
194
195    remember_queues(session, brief, active_only, active.as_deref(), &queues_seen);
196    warn_about_collisions(session, paint, &queues_seen);
197
198    // A shell that exports YTCLI_TOKEN on entering a directory — the oh-my-zsh
199    // `dotenv` plugin does exactly this — makes every profile authenticate as
200    // one person, and the rows then agree with each other for a reason that has
201    // nothing to do with the configuration being read.
202    if secrets::overridden() && session.config.profiles.len() > 1 {
203        let _ = writeln!(
204            err,
205            "{} YTCLI_TOKEN is set, so every profile above was read through that one token, whatever account it names",
206            paint.paint("warning:", Palette::warn()),
207        );
208    }
209
210    // The command someone runs to find out which profile is in play is the
211    // command that should say how to change it.
212    if session.config.profiles.len() > 1 {
213        let _ = writeln!(
214            err,
215            "{}",
216            paint.paint(
217                "change the default with: ytcli auth use <profile>",
218                Palette::label()
219            )
220        );
221    }
222
223    // The active profile decides the outcome — a broken profile nobody is using
224    // should not make a script think the tool is unusable. But if *nothing*
225    // worked, saying so beats reporting success for a run that found none.
226    active_failure
227        .or_else(|| (!any_success).then_some(last_failure).flatten())
228        .unwrap_or(ExitCode::Success)
229}
230
231/// Persist the queue map, so a later bare key can be judged without a request.
232fn remember_queues(
233    session: &Session,
234    brief: bool,
235    active_only: bool,
236    active: Option<&str>,
237    queues_seen: &std::collections::BTreeMap<String, Vec<String>>,
238) {
239    if brief {
240        return;
241    }
242
243    let cache_path = crate::config::cache::path_for(&session.config_file);
244    let mut cache = crate::config::cache::Cache::load(&cache_path);
245
246    for name in session
247        .config
248        .profiles
249        .keys()
250        .filter(|name| !active_only || active == Some(name.as_str()))
251    {
252        let keys: Vec<String> = queues_seen
253            .iter()
254            .filter(|(_, profiles)| profiles.iter().any(|profile| profile == name))
255            .map(|(key, _)| key.clone())
256            .collect();
257        cache.record(name, &keys);
258    }
259
260    cache.save(&cache_path);
261}
262
263/// Where the configuration itself came from, before anything about profiles.
264///
265/// Two questions get asked whenever this command surprises somebody: which file
266/// was read, and what in the environment is overriding it. Both are cheap to
267/// answer and neither is guessable from the rows below — a token from the
268/// environment and a token from the keychain produce identical-looking output
269/// until one of them is named.
270///
271/// Variable **names** only. One of them holds a token, and a diagnostic that
272/// prints credentials is a diagnostic nobody can paste into a bug report.
273fn report_sources(session: &Session, paint: Painter, out: &mut impl std::io::Write) {
274    let from = match std::env::var("YTCLI_CONFIG") {
275        Ok(path) if session.config_file == std::path::Path::new(&path) => "from YTCLI_CONFIG",
276        _ if session.global.config.is_some() => "from --config",
277        _ => "default location",
278    };
279
280    let _ = writeln!(
281        out,
282        "{} {} ({})",
283        paint.paint("config:", Palette::label()),
284        session.config_file.display(),
285        paint.paint(from, Palette::label()),
286    );
287
288    // Everything `YTCLI_`-prefixed: figment merges these over the file, so a
289    // value in the config that does not match what the tool is doing is usually
290    // one of these.
291    let mut overriding: Vec<String> = std::env::vars()
292        .map(|(name, _)| name)
293        .filter(|name| name.starts_with("YTCLI_") && !name.is_empty())
294        .collect();
295    overriding.sort();
296
297    if !overriding.is_empty() {
298        let _ = writeln!(
299            out,
300            "{} {}",
301            paint.paint("environment:", Palette::label()),
302            overriding.join(", "),
303        );
304    }
305}
306
307/// Say which queue keys mean two different things.
308///
309/// Two profiles seeing one queue key is only a problem when they are looking at
310/// two different organisations: then `FINANSY-1` names two issues and the tool
311/// refuses to choose. Inside one organisation it names one issue seen through
312/// two logins, either of which fetches it — warning about that would be telling
313/// the reader their setup is broken when it is working as designed.
314///
315/// Better heard here than discovered by commenting on the wrong issue.
316fn warn_about_collisions(
317    session: &Session,
318    paint: Painter,
319    queues_seen: &std::collections::BTreeMap<String, Vec<String>>,
320) {
321    let mut err = anstream::stderr();
322
323    let organisation = |name: &str| {
324        session
325            .config
326            .profiles
327            .get(name)
328            .map(|profile| profile.org_id.clone())
329    };
330
331    let ambiguous: Vec<(&String, &Vec<String>)> = queues_seen
332        .iter()
333        .filter(|(_, profiles)| {
334            profiles.len() > 1
335                && profiles
336                    .iter()
337                    .filter_map(|name| organisation(name))
338                    .collect::<std::collections::BTreeSet<_>>()
339                    .len()
340                    > 1
341        })
342        .collect();
343    if ambiguous.is_empty() {
344        return;
345    }
346
347    let _ = writeln!(err);
348    for (key, profiles) in ambiguous {
349        let _ = writeln!(
350            err,
351            "{} queue {key} is visible in {} — in different organisations, so a bare {key}-1 will be refused; write {}/{key}-1",
352            paint.paint("warning:", Palette::warn()),
353            profiles.join(" and "),
354            profiles.first().map_or("profile", String::as_str),
355        );
356    }
357}
358
359/// Everything that needs the network, for one profile.
360async fn report_profile(
361    profile: &crate::config::Profile,
362    brief: bool,
363    paint: Painter,
364    profile_name: &str,
365    queues_seen: &mut std::collections::BTreeMap<String, Vec<String>>,
366    out: &mut impl std::io::Write,
367    err: &mut impl std::io::Write,
368) -> ExitCode {
369    let (token, origin) = match secrets::token_from(&profile.account) {
370        Ok(pair) => pair,
371        Err(error) => {
372            let _ = writeln!(
373                out,
374                "  {} {}",
375                paint.paint("token:", Palette::label()),
376                paint.paint("missing", Palette::bad())
377            );
378            let _ = writeln!(err, "  {error}");
379            return ExitCode::Auth;
380        }
381    };
382
383    let mut config = ClientConfig::new(token, profile.org_id.clone(), profile.org_kind);
384    if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
385        config.base_url = base;
386    }
387    let client = match Client::new(&config) {
388        Ok(client) => client,
389        Err(error) => {
390            let _ = writeln!(err, "  {error}");
391            return error.exit_code();
392        }
393    };
394
395    // Which token answered, when it is not the one this profile's account
396    // holds. Without this the reader has no way to tell that every profile is
397    // being read through one identity.
398    let via = match origin {
399        secrets::Origin::Environment => " (from YTCLI_TOKEN)",
400        // Named rather than left blank: "where did this credential come from"
401        // is the question, and an unlabelled answer is only obvious to whoever
402        // wrote the tool.
403        secrets::Origin::Keychain => " (from keychain)",
404    };
405
406    match client.myself().await {
407        Ok(user) => {
408            let _ = writeln!(
409                out,
410                "  {} {}{via}   {} {}{}",
411                paint.paint("token:", Palette::label()),
412                paint.paint("ok", Palette::ok()),
413                paint.paint("user:", Palette::label()),
414                user.login.as_deref().unwrap_or(&user.id),
415                user.display
416                    .as_deref()
417                    .map_or_else(String::new, |display| format!(" ({display})")),
418            );
419        }
420        Err(error) => {
421            let _ = writeln!(
422                out,
423                "  {} {}",
424                paint.paint("token:", Palette::label()),
425                paint.paint("rejected", Palette::bad())
426            );
427            let _ = writeln!(err, "  {error}");
428            if matches!(error, crate::api::error::ApiError::Unauthorized) {
429                let _ = writeln!(err, "\n{}", guidance::block(guidance::TOKEN));
430            }
431            return error.exit_code();
432        }
433    }
434
435    if brief {
436        return ExitCode::Success;
437    }
438
439    reach(&client, paint, profile_name, queues_seen, out).await;
440    ExitCode::Success
441}
442
443/// What this profile can actually see.
444///
445/// Every lookup is best-effort: a profile without access to projects should
446/// still report its queues rather than losing the whole line.
447async fn reach(
448    client: &Client,
449    paint: Painter,
450    profile_name: &str,
451    queues_seen: &mut std::collections::BTreeMap<String, Vec<String>>,
452    out: &mut impl std::io::Write,
453) {
454    let queues = client.queues().await.ok();
455    let projects = client.entities("project", None, 1, 5).await.ok();
456    let goals = client.entities("goal", None, 1, 1).await.ok();
457    let mine = client
458        .count("Assignee: me() AND Resolution: empty()")
459        .await
460        .ok();
461
462    let _ = writeln!(
463        out,
464        "  {} {}   {} {}   {} {}   {} {}",
465        paint.paint("queues:", Palette::label()),
466        queues
467            .as_ref()
468            .map_or_else(|| "-".to_owned(), |queues| queues.len().to_string()),
469        paint.paint("projects:", Palette::label()),
470        projects.as_ref().map_or_else(|| "-".to_owned(), count_of),
471        paint.paint("goals:", Palette::label()),
472        goals.as_ref().map_or_else(|| "-".to_owned(), count_of),
473        paint.paint("my open issues:", Palette::label()),
474        mine.map_or_else(|| "-".to_owned(), |count| count.to_string()),
475    );
476
477    if let Some(projects) = projects.filter(|page| !page.items.is_empty()) {
478        let names: Vec<String> = projects
479            .items
480            .iter()
481            .map(|project| {
482                project.short_id.map_or_else(
483                    || project.summary.clone(),
484                    |id| format!("{} ({id})", project.summary),
485                )
486            })
487            .collect();
488        let more = projects
489            .total
490            .unwrap_or(names.len() as u64)
491            .saturating_sub(names.len() as u64);
492        let suffix = if more > 0 {
493            format!(", +{more} more")
494        } else {
495            String::new()
496        };
497        let _ = writeln!(
498            out,
499            "  {} {}{suffix}",
500            paint.paint("projects:", Palette::label()),
501            names.join(", ")
502        );
503    }
504
505    if let Some(queues) = queues.filter(|queues| !queues.is_empty()) {
506        for queue in &queues {
507            queues_seen
508                .entry(queue.key.clone())
509                .or_default()
510                .push(profile_name.to_owned());
511        }
512
513        let keys: Vec<&str> = queues
514            .iter()
515            .take(8)
516            .map(|queue| queue.key.as_str())
517            .collect();
518        let more = queues.len().saturating_sub(keys.len());
519        let suffix = if more > 0 {
520            format!(", +{more} more")
521        } else {
522            String::new()
523        };
524        let _ = writeln!(
525            out,
526            "  {} {}{suffix}",
527            paint.paint("queues:", Palette::label()),
528            keys.join(", ")
529        );
530    }
531}
532
533fn count_of<T>(page: &crate::api::models::Page<T>) -> String {
534    page.total
535        .map_or_else(|| page.items.len().to_string(), |total| total.to_string())
536}
537
538/// Read the token, check it, store it, and write the config to use it.
539///
540/// Flags and prompts are the same path: whatever was passed is taken as given,
541/// and anything missing is asked for — but only when someone is there to answer.
542/// Outside a terminal the flags are all there is, and a gap is an error rather
543/// than a prompt nobody will ever see.
544async fn login(args: &LoginArgs, session: &Session) -> ExitCode {
545    let interactive = wizard::is_interactive();
546    let mut err = anstream::stderr();
547
548    // Interactive login always asks for the token — there is no flag to pass one
549    // in, on purpose — so there is always something the procedure is needed for.
550    if interactive {
551        wizard::introduce();
552    }
553
554    let Identity {
555        account,
556        token,
557        org_id,
558        org_kind: verified,
559    } = match identity(args, session, interactive).await {
560        Ok(identity) => identity,
561        Err(code) => return code,
562    };
563
564    if session.global.dry_run {
565        let _ = writeln!(
566            err,
567            "dry run: would store a token for `{account}` in the OS keychain"
568        );
569    } else {
570        if let Err(error) = secrets::store(&account, &token) {
571            return report(&error, ExitCode::Auth);
572        }
573        let _ = writeln!(err, "stored a token for `{account}` in the OS keychain");
574    }
575
576    let Some(org_id) = org_id else {
577        let _ = writeln!(
578            err,
579            "no --org-id given, so no profile was written and nothing can be queried yet.\n"
580        );
581        let _ = writeln!(err, "{}", guidance::block(guidance::ORG));
582        let _ = writeln!(
583            err,
584            "\nThen: ytcli auth login --account {account} --org-id <id> [--queue <QUEUE>]"
585        );
586        return ExitCode::Success;
587    };
588
589    let org_kind = verified.unwrap_or(OrgKind::Cloud);
590
591    let shape = Shape {
592        account: &account,
593        token: &token,
594        org_id: &org_id,
595        org_kind,
596        interactive,
597    };
598    let (profile_name, profile, make_default) = match shape_profile(args, session, &shape).await {
599        Ok(shaped) => shaped,
600        Err(code) => return code,
601    };
602
603    if session.global.dry_run {
604        let _ = writeln!(
605            err,
606            "dry run: would write profile `{profile_name}` (account={}, org={}, {:?}{}) to {}",
607            profile.account,
608            profile.org_id,
609            profile.org_kind,
610            if make_default { ", default" } else { "" },
611            session.config_file.display(),
612        );
613        return ExitCode::Success;
614    }
615
616    match store::upsert(
617        &session.config_file,
618        &account,
619        None,
620        Some((&profile_name, &profile)),
621        make_default,
622    ) {
623        Ok(_) => {
624            let _ = writeln!(
625                err,
626                "wrote profile `{profile_name}` to {}{}",
627                session.config_file.display(),
628                if make_default { " (default)" } else { "" },
629            );
630            let _ = writeln!(err, "try it: ytcli auth status --active-only");
631            emit(&format!("{profile_name}\n"));
632            ExitCode::Success
633        }
634        Err(error) => report(&error, ExitCode::Failure),
635    }
636}
637
638/// Who is logging in, where, and with what — everything settled before anything
639/// is written.
640struct Identity {
641    account: String,
642    token: String,
643    org_id: Option<String>,
644    /// The organisation flavour that answered, once verified.
645    org_kind: Option<OrgKind>,
646}
647
648/// Collect and check the credentials.
649///
650/// Flags win; a terminal fills the gaps; outside one, a gap is an error rather
651/// than a prompt nobody will see.
652async fn identity(
653    args: &LoginArgs,
654    session: &Session,
655    interactive: bool,
656) -> Result<Identity, ExitCode> {
657    let mut err = anstream::stderr();
658
659    let account = match args.account.clone() {
660        Some(account) => account,
661        None if interactive => {
662            let existing: Vec<String> = session.config.accounts.keys().cloned().collect();
663            wizard::account(&existing).map_err(|error| report(&error, error.exit_code()))?
664        }
665        None => {
666            return Err(report(
667                &"--account is required when not running in a terminal",
668                ExitCode::ConfirmationRequired,
669            ));
670        }
671    };
672
673    let token = read_token(&account, interactive)?;
674
675    // The organisation decides whether a profile can be written at all, so it is
676    // asked for rather than skipped when someone is there to answer.
677    let (org_id, org_kind) = match (&args.org_id, interactive) {
678        (Some(org_id), _) => (Some(org_id.clone()), args.org_kind),
679        (None, true) => wizard::organisation()
680            .map(|(id, kind)| (Some(id), kind))
681            .map_err(|error| report(&error, error.exit_code()))?,
682        (None, false) => (None, None),
683    };
684
685    let verified = match (&org_id, args.no_verify) {
686        (Some(org_id), false) => {
687            let (kind, who) = verify(&token, org_id, org_kind).await?;
688            let _ = writeln!(err, "verified as {who} in org {org_id} ({kind:?})");
689            Some(kind)
690        }
691        (Some(_), true) => Some(org_kind.unwrap_or(OrgKind::Cloud)),
692        (None, _) => None,
693    };
694
695    Ok(Identity {
696        account,
697        token,
698        org_id,
699        org_kind: verified,
700    })
701}
702
703/// What the profile is being built from, once identity is settled.
704struct Shape<'a> {
705    account: &'a str,
706    token: &'a str,
707    org_id: &'a str,
708    org_kind: OrgKind,
709    interactive: bool,
710}
711
712/// Decide the profile's name, its queue and whether it becomes the default.
713///
714/// Split out so each half of login stays readable: this one asks questions and
715/// touches nothing.
716async fn shape_profile(
717    args: &LoginArgs,
718    session: &Session,
719    shape: &Shape<'_>,
720) -> Result<(String, Profile, bool), ExitCode> {
721    let profile_name = match args.profile.clone() {
722        Some(name) => name,
723        None if shape.interactive => {
724            wizard::profile(shape.account).map_err(|error| report(&error, error.exit_code()))?
725        }
726        None => shape.account.to_owned(),
727    };
728
729    // Offer the queues this token can actually see. Verifying first is what makes
730    // that possible, and turns a spelling test into a choice.
731    let queue = match args.queue.clone() {
732        Some(queue) => Some(queue),
733        None if shape.interactive => {
734            let available = queue_keys(shape.token, shape.org_id, shape.org_kind).await;
735
736            // Listing them anyway makes recording them free, and a collision
737            // with an existing profile can then be caught on the next command
738            // rather than after acting on the wrong issue.
739            if !session.global.dry_run {
740                let cache_path = crate::config::cache::path_for(&session.config_file);
741                let mut cache = crate::config::cache::Cache::load(&cache_path);
742                cache.record(&profile_name, &available);
743                cache.save(&cache_path);
744            }
745
746            wizard::queue(&available).map_err(|error| report(&error, error.exit_code()))?
747        }
748        None => None,
749    };
750
751    let current_default = session.config.default_profile.as_deref();
752    let make_default = if args.default || current_default.is_none() {
753        true
754    } else if shape.interactive {
755        wizard::make_default(&profile_name, current_default)
756            .map_err(|error| report(&error, error.exit_code()))?
757    } else {
758        false
759    };
760
761    Ok((
762        profile_name,
763        Profile {
764            account: shape.account.to_owned(),
765            org_id: shape.org_id.to_owned(),
766            org_kind: shape.org_kind,
767            default_queue: queue,
768            display: crate::config::Display::default(),
769        },
770        make_default,
771    ))
772}
773
774/// Queue keys this token can see, for the picker. Best-effort: failing to list
775/// them costs a dropdown, not the login.
776async fn queue_keys(token: &str, org_id: &str, kind: OrgKind) -> Vec<String> {
777    let mut config = ClientConfig::new(token.to_owned(), org_id.to_owned(), kind);
778    if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
779        config.base_url = base;
780    }
781
782    let Ok(client) = Client::new(&config) else {
783        return Vec::new();
784    };
785
786    client.queues().await.map_or_else(
787        |_| Vec::new(),
788        |queues| queues.into_iter().map(|queue| queue.key).collect(),
789    )
790}
791
792/// Read the token: a hidden prompt when someone is typing, stdin when piped.
793fn read_token(account: &str, interactive: bool) -> Result<String, ExitCode> {
794    if interactive {
795        return wizard::token(account).map_err(|error| report(&error, error.exit_code()));
796    }
797
798    let mut piped = String::new();
799    std::io::Read::read_to_string(&mut std::io::stdin(), &mut piped)
800        .map_err(|error| report(&error, ExitCode::Failure))?;
801
802    let token = piped.trim().to_owned();
803    if token.is_empty() {
804        return Err(report(&"no token given", ExitCode::Auth));
805    }
806    Ok(token)
807}
808
809/// Check the token against the API, working out which organisation header it
810/// needs if that was not said.
811///
812/// The two header forms are not interchangeable and the wrong one answers 403,
813/// which reads like a permissions problem rather than a configuration mistake.
814/// Trying both here is one extra request, once, against an afternoon of
815/// confusion later.
816async fn verify(
817    token: &str,
818    org_id: &str,
819    kind: Option<OrgKind>,
820) -> Result<(OrgKind, String), ExitCode> {
821    let candidates: Vec<OrgKind> = match kind {
822        Some(kind) => vec![kind],
823        None => vec![OrgKind::Cloud, OrgKind::Yandex360],
824    };
825
826    let mut last: Option<crate::api::error::ApiError> = None;
827
828    for candidate in candidates {
829        let mut config = ClientConfig::new(token.to_owned(), org_id.to_owned(), candidate);
830        if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
831            config.base_url = base;
832        }
833
834        let client = match Client::new(&config) {
835            Ok(client) => client,
836            Err(error) => {
837                let code = error.exit_code();
838                return Err(report(&error, code));
839            }
840        };
841
842        match client.myself().await {
843            Ok(user) => {
844                let who = user.login.or(user.display).unwrap_or(user.id);
845                return Ok((candidate, who));
846            }
847            // A rejected token is rejected under either header; only an
848            // organisation mismatch is worth retrying the other way.
849            Err(error @ crate::api::error::ApiError::Unauthorized) => {
850                let code = error.exit_code();
851                let reported = report(&error, code);
852                let mut err = anstream::stderr();
853                let _ = writeln!(err, "\n{}", guidance::block(guidance::TOKEN));
854                return Err(reported);
855            }
856            Err(error) => last = Some(error),
857        }
858    }
859
860    let error = last.unwrap_or(crate::api::error::ApiError::Forbidden);
861    let code = error.exit_code();
862    let reported = report(
863        &format!("{error} — checked both organisation header forms"),
864        code,
865    );
866    let mut err = anstream::stderr();
867    let _ = writeln!(err, "\n{}", guidance::block(guidance::ORG));
868    Err(reported)
869}
870
871/// Point `default_profile` at another profile.
872///
873/// A local edit and nothing else: no token is read, no request is made. The
874/// profile has to exist, because a default naming a profile that does not is a
875/// config every later command fails on with a worse message than this one.
876fn use_profile(session: &Session, profile: &str) -> ExitCode {
877    let mut err = anstream::stderr();
878
879    if !session.config.profiles.contains_key(profile) {
880        let known: Vec<&str> = session.config.profiles.keys().map(String::as_str).collect();
881        return report(
882            &format!(
883                "no profile called `{profile}`; configured: {}",
884                if known.is_empty() {
885                    "none — run `ytcli auth login`".to_owned()
886                } else {
887                    known.join(", ")
888                }
889            ),
890            ExitCode::NotFound,
891        );
892    }
893
894    let previous = session.config.default_profile.clone();
895    if previous.as_deref() == Some(profile) {
896        let _ = writeln!(err, "`{profile}` is already the default profile");
897        return ExitCode::Success;
898    }
899
900    if session.global.dry_run {
901        let _ = writeln!(
902            err,
903            "dry run: would make `{profile}` the default profile in {}",
904            session.config_file.display()
905        );
906        return ExitCode::Success;
907    }
908
909    match store::set_default(&session.config_file, profile) {
910        Ok(_) => {
911            let _ = writeln!(
912                err,
913                "default profile: {} → {profile}",
914                previous.as_deref().unwrap_or("none"),
915            );
916            ExitCode::Success
917        }
918        Err(error) => report(&error, ExitCode::Failure),
919    }
920}
921
922fn logout(account: &str) -> ExitCode {
923    match secrets::forget(account) {
924        Ok(()) => {
925            let mut err = anstream::stderr();
926            let _ = writeln!(err, "forgot the token for `{account}`");
927            ExitCode::Success
928        }
929        Err(error) => report(&error, ExitCode::Auth),
930    }
931}
932
933/// Accounts and the profiles pointing at them.
934///
935/// Whether a token exists is shown; the token never is.
936fn list(session: &Session) -> ExitCode {
937    let mut out = String::with_capacity(256);
938
939    let active = session
940        .resolved
941        .as_ref()
942        .map(|resolved| resolved.name.clone());
943
944    for (name, account) in &session.config.accounts {
945        let _ = writeln!(
946            out,
947            "account {name}  token: {}  {}",
948            if secrets::is_stored(name) {
949                "stored"
950            } else {
951                "missing"
952            },
953            account.description.as_deref().unwrap_or(""),
954        );
955    }
956
957    for (name, profile) in &session.config.profiles {
958        let marks = [
959            (session.config.default_profile.as_deref() == Some(name.as_str())).then_some("default"),
960            (active.as_deref() == Some(name.as_str())).then_some("active"),
961        ];
962        let marks: Vec<&str> = marks.into_iter().flatten().collect();
963        let suffix = if marks.is_empty() {
964            String::new()
965        } else {
966            format!("  [{}]", marks.join(", "))
967        };
968
969        let _ = writeln!(
970            out,
971            "profile {name}  account: {}  org: {} ({:?}){suffix}",
972            profile.account, profile.org_id, profile.org_kind,
973        );
974    }
975
976    if out.is_empty() {
977        return report(
978            &"no accounts or profiles configured yet; see `ytcli auth login --help`",
979            ExitCode::Auth,
980        );
981    }
982
983    emit(&out);
984    ExitCode::Success
985}