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    /// Change an existing profile: its name, its note, the organisation it points at.
41    #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_EDIT))]
42    Edit(EditArgs),
43    /// Delete a profile from the config file. The account and its token stay.
44    #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_REMOVE))]
45    Remove {
46        /// Profile name, as `auth list` prints it.
47        profile: String,
48    },
49    /// Check every profile: who the token belongs to, and what it can see.
50    #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_STATUS))]
51    Status {
52        /// Identity only — skip the counts, and the requests behind them.
53        #[arg(long)]
54        brief: bool,
55        /// Check only the active profile instead of all of them.
56        #[arg(long)]
57        active_only: bool,
58    },
59}
60
61/// Arguments for `auth login`.
62///
63/// The token is deliberately absent: it is read from a prompt or from stdin,
64/// never from an argument, because arguments are visible in `ps` and land in
65/// shell history.
66#[derive(Debug, Args)]
67pub struct LoginArgs {
68    /// Account name to store the token under. Asked for when omitted.
69    #[arg(long, short = 'a')]
70    pub account: Option<String>,
71
72    /// Organisation id. Given this, login also writes a profile.
73    #[arg(long)]
74    pub org_id: Option<String>,
75
76    /// Which header carries the organisation id. Detected when omitted.
77    #[arg(long, value_enum)]
78    pub org_kind: Option<OrgKind>,
79
80    /// Profile name to create; defaults to the account name.
81    #[arg(long, short = 'p')]
82    pub profile: Option<String>,
83
84    /// Queue this profile assumes when a command needs one.
85    #[arg(long, short = 'q')]
86    pub queue: Option<String>,
87
88    /// Note saying which organisation this profile is; shown wherever the
89    /// profile is named. Asked for in a terminal, and left as it was when a
90    /// re-login omits it.
91    #[arg(long)]
92    pub description: Option<String>,
93
94    /// Make this the default profile even if another one already is.
95    #[arg(long)]
96    pub default: bool,
97
98    /// Skip the check that the token and organisation actually work.
99    #[arg(long)]
100    pub no_verify: bool,
101}
102
103/// Arguments for `auth edit`.
104///
105/// Everything is optional except the profile, and anything not passed is left
106/// exactly as it was: this command exists to change one thing without having to
107/// restate the rest of a profile that already works.
108#[derive(Debug, Args)]
109pub struct EditArgs {
110    /// Profile to change, as `auth list` prints it.
111    pub profile: String,
112
113    /// Rename it. `default_profile` follows; a committed `.tracker.toml` does not.
114    #[arg(long)]
115    pub name: Option<String>,
116
117    /// Note saying which organisation this is.
118    #[arg(long)]
119    pub description: Option<String>,
120
121    /// Remove the note.
122    #[arg(long, conflicts_with = "description")]
123    pub clear_description: bool,
124
125    /// Account whose credential this profile uses.
126    #[arg(long, short = 'a')]
127    pub account: Option<String>,
128
129    /// Organisation id.
130    #[arg(long)]
131    pub org_id: Option<String>,
132
133    /// Which header carries the organisation id.
134    #[arg(long, value_enum)]
135    pub org_kind: Option<OrgKind>,
136
137    /// Queue assumed when a command needs one and none was given.
138    #[arg(long, short = 'q')]
139    pub queue: Option<String>,
140
141    /// Stop assuming a queue.
142    #[arg(long, conflicts_with = "queue")]
143    pub clear_queue: bool,
144}
145
146/// Run an auth subcommand.
147pub async fn run(command: &AuthCommand, session: &Session) -> ExitCode {
148    match command {
149        AuthCommand::Status { brief, active_only } => status(session, *brief, *active_only).await,
150        AuthCommand::Login(args) => login(args, session).await,
151        AuthCommand::Logout { account } => logout(account),
152        AuthCommand::List => list(session),
153        AuthCommand::Use { profile } => use_profile(session, profile),
154        AuthCommand::Edit(args) => edit(args, session),
155        AuthCommand::Remove { profile } => remove(session, profile),
156    }
157}
158
159/// Report on the configured profiles.
160///
161/// This is the command someone runs when something is wrong, so it answers the
162/// questions that actually get asked: which profile is in play and where that
163/// choice came from, whether the token works, who it belongs to, and what it can
164/// reach. Checking every profile rather than only the active one is deliberate —
165/// "it works with my other login" is the usual next question.
166///
167/// The counts cost a handful of requests per profile. That is fine for a
168/// diagnostic and wrong for a hot path, which is what `--brief` is for.
169async fn status(session: &Session, brief: bool, active_only: bool) -> ExitCode {
170    let mut out = anstream::stdout();
171    let mut err = anstream::stderr();
172    let paint = session.render.painter();
173
174    if session.config.profiles.is_empty() {
175        let _ = writeln!(err, "no profiles configured yet.\n");
176        let _ = writeln!(err, "{}", guidance::full());
177        let _ = writeln!(
178            err,
179            "Then: ytcli auth login --account <name> --org-id <id> [--queue <QUEUE>]"
180        );
181        return ExitCode::Auth;
182    }
183
184    report_sources(session, paint, &mut out);
185
186    let active = session
187        .resolved
188        .as_ref()
189        .map(|resolved| resolved.name.clone());
190    let mut active_failure = None;
191    let mut any_success = false;
192    let mut last_failure = None;
193    // Which profiles can see each queue key, so the ambiguity can be reported.
194    let mut queues_seen: std::collections::BTreeMap<String, Vec<String>> =
195        std::collections::BTreeMap::new();
196
197    for (name, profile) in &session.config.profiles {
198        let is_active = active.as_deref() == Some(name.as_str());
199        if active_only && !is_active {
200            continue;
201        }
202
203        let source = if is_active {
204            session
205                .resolved
206                .as_ref()
207                .map_or_else(String::new, |resolved| {
208                    format!(" (from {})", resolved.source)
209                })
210        } else {
211            String::new()
212        };
213        let marks = if is_active { "  [active]" } else { "" };
214
215        let _ = writeln!(
216            out,
217            "{} {}{}{}",
218            paint.paint("profile", Palette::label()),
219            paint.paint(name, Palette::key()),
220            paint.paint(&source, Palette::label()),
221            paint.paint(marks, Palette::ok()),
222        );
223        describe_profile(profile, paint, &mut out);
224
225        let code = report_profile(
226            profile,
227            brief,
228            paint,
229            name,
230            &mut queues_seen,
231            &mut out,
232            &mut err,
233        )
234        .await;
235        if code == ExitCode::Success {
236            any_success = true;
237        } else {
238            last_failure = Some(code);
239            if is_active {
240                active_failure = Some(code);
241            }
242        }
243    }
244
245    remember_queues(session, brief, active_only, active.as_deref(), &queues_seen);
246    warn_about_collisions(session, paint, &queues_seen);
247
248    // A shell that exports YTCLI_TOKEN on entering a directory — the oh-my-zsh
249    // `dotenv` plugin does exactly this — makes every profile authenticate as
250    // one person, and the rows then agree with each other for a reason that has
251    // nothing to do with the configuration being read.
252    if secrets::overridden() && session.config.profiles.len() > 1 {
253        let _ = writeln!(
254            err,
255            "{} YTCLI_TOKEN is set, so every profile above was read through that one token, whatever account it names",
256            paint.paint("warning:", Palette::warn()),
257        );
258    }
259
260    // The command someone runs to find out which profile is in play is the
261    // command that should say how to change it.
262    if session.config.profiles.len() > 1 {
263        let _ = writeln!(
264            err,
265            "{}",
266            paint.paint(
267                "change the default with: ytcli auth use <profile>",
268                Palette::label()
269            )
270        );
271    }
272
273    // The active profile decides the outcome — a broken profile nobody is using
274    // should not make a script think the tool is unusable. But if *nothing*
275    // worked, saying so beats reporting success for a run that found none.
276    active_failure
277        .or_else(|| (!any_success).then_some(last_failure).flatten())
278        .unwrap_or(ExitCode::Success)
279}
280
281/// The two lines under a profile heading: its note, then what it points at.
282fn describe_profile(
283    profile: &crate::config::Profile,
284    paint: Painter,
285    out: &mut impl std::io::Write,
286) {
287    if let Some(description) = profile.description.as_deref() {
288        let _ = writeln!(
289            out,
290            "  {} {description}",
291            paint.paint("note:", Palette::label()),
292        );
293    }
294
295    let _ = writeln!(
296        out,
297        "  {} {}   {} {} ({:?})   {} {}",
298        paint.paint("account:", Palette::label()),
299        profile.account,
300        paint.paint("org:", Palette::label()),
301        profile.org_id,
302        profile.org_kind,
303        paint.paint("queue:", Palette::label()),
304        profile.default_queue.as_deref().unwrap_or("-"),
305    );
306}
307
308/// Persist the queue map, so a later bare key can be judged without a request.
309fn remember_queues(
310    session: &Session,
311    brief: bool,
312    active_only: bool,
313    active: Option<&str>,
314    queues_seen: &std::collections::BTreeMap<String, Vec<String>>,
315) {
316    if brief {
317        return;
318    }
319
320    let cache_path = crate::config::cache::path_for(&session.config_file);
321    let mut cache = crate::config::cache::Cache::load(&cache_path);
322
323    for name in session
324        .config
325        .profiles
326        .keys()
327        .filter(|name| !active_only || active == Some(name.as_str()))
328    {
329        let keys: Vec<String> = queues_seen
330            .iter()
331            .filter(|(_, profiles)| profiles.iter().any(|profile| profile == name))
332            .map(|(key, _)| key.clone())
333            .collect();
334        cache.record(name, &keys);
335    }
336
337    cache.save(&cache_path);
338}
339
340/// Where the configuration itself came from, before anything about profiles.
341///
342/// Two questions get asked whenever this command surprises somebody: which file
343/// was read, and what in the environment is overriding it. Both are cheap to
344/// answer and neither is guessable from the rows below — a token from the
345/// environment and a token from the keychain produce identical-looking output
346/// until one of them is named.
347///
348/// Variable **names** only. One of them holds a token, and a diagnostic that
349/// prints credentials is a diagnostic nobody can paste into a bug report.
350fn report_sources(session: &Session, paint: Painter, out: &mut impl std::io::Write) {
351    let from = match std::env::var("YTCLI_CONFIG") {
352        Ok(path) if session.config_file == std::path::Path::new(&path) => "from YTCLI_CONFIG",
353        _ if session.global.config.is_some() => "from --config",
354        _ => "default location",
355    };
356
357    let _ = writeln!(
358        out,
359        "{} {} ({})",
360        paint.paint("config:", Palette::label()),
361        session.config_file.display(),
362        paint.paint(from, Palette::label()),
363    );
364
365    // Everything `YTCLI_`-prefixed: figment merges these over the file, so a
366    // value in the config that does not match what the tool is doing is usually
367    // one of these.
368    let mut overriding: Vec<String> = std::env::vars()
369        .map(|(name, _)| name)
370        .filter(|name| name.starts_with("YTCLI_") && !name.is_empty())
371        .collect();
372    overriding.sort();
373
374    if !overriding.is_empty() {
375        let _ = writeln!(
376            out,
377            "{} {}",
378            paint.paint("environment:", Palette::label()),
379            overriding.join(", "),
380        );
381    }
382}
383
384/// Say which queue keys mean two different things.
385///
386/// Two profiles seeing one queue key is only a problem when they are looking at
387/// two different organisations: then `FINANSY-1` names two issues and the tool
388/// refuses to choose. Inside one organisation it names one issue seen through
389/// two logins, either of which fetches it — warning about that would be telling
390/// the reader their setup is broken when it is working as designed.
391///
392/// Better heard here than discovered by commenting on the wrong issue.
393fn warn_about_collisions(
394    session: &Session,
395    paint: Painter,
396    queues_seen: &std::collections::BTreeMap<String, Vec<String>>,
397) {
398    let mut err = anstream::stderr();
399
400    let organisation = |name: &str| {
401        session
402            .config
403            .profiles
404            .get(name)
405            .map(|profile| profile.org_id.clone())
406    };
407
408    let ambiguous: Vec<(&String, &Vec<String>)> = queues_seen
409        .iter()
410        .filter(|(_, profiles)| {
411            profiles.len() > 1
412                && profiles
413                    .iter()
414                    .filter_map(|name| organisation(name))
415                    .collect::<std::collections::BTreeSet<_>>()
416                    .len()
417                    > 1
418        })
419        .collect();
420    if ambiguous.is_empty() {
421        return;
422    }
423
424    let _ = writeln!(err);
425    for (key, profiles) in ambiguous {
426        let _ = writeln!(
427            err,
428            "{} queue {key} is visible in {} — in different organisations, so a bare {key}-1 will be refused; write {}/{key}-1",
429            paint.paint("warning:", Palette::warn()),
430            profiles.join(" and "),
431            profiles.first().map_or("profile", String::as_str),
432        );
433    }
434}
435
436/// Everything that needs the network, for one profile.
437async fn report_profile(
438    profile: &crate::config::Profile,
439    brief: bool,
440    paint: Painter,
441    profile_name: &str,
442    queues_seen: &mut std::collections::BTreeMap<String, Vec<String>>,
443    out: &mut impl std::io::Write,
444    err: &mut impl std::io::Write,
445) -> ExitCode {
446    let (token, origin) = match secrets::token_from(&profile.account) {
447        Ok(pair) => pair,
448        Err(error) => {
449            let _ = writeln!(
450                out,
451                "  {} {}",
452                paint.paint("token:", Palette::label()),
453                paint.paint("missing", Palette::bad())
454            );
455            let _ = writeln!(err, "  {error}");
456            return ExitCode::Auth;
457        }
458    };
459
460    let mut config = ClientConfig::new(token, profile.org_id.clone(), profile.org_kind);
461    if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
462        config.base_url = base;
463    }
464    let client = match Client::new(&config) {
465        Ok(client) => client,
466        Err(error) => {
467            let _ = writeln!(err, "  {error}");
468            return error.exit_code();
469        }
470    };
471
472    // Which token answered, when it is not the one this profile's account
473    // holds. Without this the reader has no way to tell that every profile is
474    // being read through one identity.
475    let via = match origin {
476        secrets::Origin::Environment => " (from YTCLI_TOKEN)",
477        // Named rather than left blank: "where did this credential come from"
478        // is the question, and an unlabelled answer is only obvious to whoever
479        // wrote the tool.
480        secrets::Origin::Keychain => " (from keychain)",
481    };
482
483    match client.myself().await {
484        Ok(user) => {
485            let _ = writeln!(
486                out,
487                "  {} {}{via}   {} {}{}",
488                paint.paint("token:", Palette::label()),
489                paint.paint("ok", Palette::ok()),
490                paint.paint("user:", Palette::label()),
491                user.login.as_deref().unwrap_or(&user.id),
492                user.display
493                    .as_deref()
494                    .map_or_else(String::new, |display| format!(" ({display})")),
495            );
496        }
497        Err(error) => {
498            let _ = writeln!(
499                out,
500                "  {} {}",
501                paint.paint("token:", Palette::label()),
502                paint.paint("rejected", Palette::bad())
503            );
504            let _ = writeln!(err, "  {error}");
505            if matches!(error, crate::api::error::ApiError::Unauthorized) {
506                let _ = writeln!(err, "\n{}", guidance::block(guidance::TOKEN));
507            }
508            return error.exit_code();
509        }
510    }
511
512    if brief {
513        return ExitCode::Success;
514    }
515
516    reach(&client, paint, profile_name, queues_seen, out).await;
517    ExitCode::Success
518}
519
520/// What this profile can actually see.
521///
522/// Every lookup is best-effort: a profile without access to projects should
523/// still report its queues rather than losing the whole line.
524async fn reach(
525    client: &Client,
526    paint: Painter,
527    profile_name: &str,
528    queues_seen: &mut std::collections::BTreeMap<String, Vec<String>>,
529    out: &mut impl std::io::Write,
530) {
531    let queues = client.queues().await.ok();
532    let projects = client.entities("project", None, 1, 5).await.ok();
533    let goals = client.entities("goal", None, 1, 1).await.ok();
534    let mine = client
535        .count("Assignee: me() AND Resolution: empty()")
536        .await
537        .ok();
538
539    let _ = writeln!(
540        out,
541        "  {} {}   {} {}   {} {}   {} {}",
542        paint.paint("queues:", Palette::label()),
543        queues
544            .as_ref()
545            .map_or_else(|| "-".to_owned(), |queues| queues.len().to_string()),
546        paint.paint("projects:", Palette::label()),
547        projects.as_ref().map_or_else(|| "-".to_owned(), count_of),
548        paint.paint("goals:", Palette::label()),
549        goals.as_ref().map_or_else(|| "-".to_owned(), count_of),
550        paint.paint("my open issues:", Palette::label()),
551        mine.map_or_else(|| "-".to_owned(), |count| count.to_string()),
552    );
553
554    if let Some(projects) = projects.filter(|page| !page.items.is_empty()) {
555        let names: Vec<String> = projects
556            .items
557            .iter()
558            .map(|project| {
559                project.short_id.map_or_else(
560                    || project.summary.clone(),
561                    |id| format!("{} ({id})", project.summary),
562                )
563            })
564            .collect();
565        let more = projects
566            .total
567            .unwrap_or(names.len() as u64)
568            .saturating_sub(names.len() as u64);
569        let suffix = if more > 0 {
570            format!(", +{more} more")
571        } else {
572            String::new()
573        };
574        let _ = writeln!(
575            out,
576            "  {} {}{suffix}",
577            paint.paint("projects:", Palette::label()),
578            names.join(", ")
579        );
580    }
581
582    if let Some(queues) = queues.filter(|queues| !queues.is_empty()) {
583        for queue in &queues {
584            queues_seen
585                .entry(queue.key.clone())
586                .or_default()
587                .push(profile_name.to_owned());
588        }
589
590        let keys: Vec<&str> = queues
591            .iter()
592            .take(8)
593            .map(|queue| queue.key.as_str())
594            .collect();
595        let more = queues.len().saturating_sub(keys.len());
596        let suffix = if more > 0 {
597            format!(", +{more} more")
598        } else {
599            String::new()
600        };
601        let _ = writeln!(
602            out,
603            "  {} {}{suffix}",
604            paint.paint("queues:", Palette::label()),
605            keys.join(", ")
606        );
607    }
608}
609
610fn count_of<T>(page: &crate::api::models::Page<T>) -> String {
611    page.total
612        .map_or_else(|| page.items.len().to_string(), |total| total.to_string())
613}
614
615/// Read the token, check it, store it, and write the config to use it.
616///
617/// Flags and prompts are the same path: whatever was passed is taken as given,
618/// and anything missing is asked for — but only when someone is there to answer.
619/// Outside a terminal the flags are all there is, and a gap is an error rather
620/// than a prompt nobody will ever see.
621async fn login(args: &LoginArgs, session: &Session) -> ExitCode {
622    let interactive = wizard::is_interactive();
623    let mut err = anstream::stderr();
624
625    // Interactive login always asks for the token — there is no flag to pass one
626    // in, on purpose — so there is always something the procedure is needed for.
627    if interactive {
628        wizard::introduce();
629    }
630
631    let Identity {
632        account,
633        token,
634        org_id,
635        org_kind: verified,
636    } = match identity(args, session, interactive).await {
637        Ok(identity) => identity,
638        Err(code) => return code,
639    };
640
641    if session.global.dry_run {
642        let _ = writeln!(
643            err,
644            "dry run: would store a token for `{account}` in the OS keychain"
645        );
646    } else {
647        if let Err(error) = secrets::store(&account, &token) {
648            return report(&error, ExitCode::Auth);
649        }
650        let _ = writeln!(err, "stored a token for `{account}` in the OS keychain");
651    }
652
653    let Some(org_id) = org_id else {
654        let _ = writeln!(
655            err,
656            "no --org-id given, so no profile was written and nothing can be queried yet.\n"
657        );
658        let _ = writeln!(err, "{}", guidance::block(guidance::ORG));
659        let _ = writeln!(
660            err,
661            "\nThen: ytcli auth login --account {account} --org-id <id> [--queue <QUEUE>]"
662        );
663        return ExitCode::Success;
664    };
665
666    let org_kind = verified.unwrap_or(OrgKind::Cloud);
667
668    let shape = Shape {
669        account: &account,
670        token: &token,
671        org_id: &org_id,
672        org_kind,
673        interactive,
674    };
675    let (profile_name, profile, make_default) = match shape_profile(args, session, &shape).await {
676        Ok(shaped) => shaped,
677        Err(code) => return code,
678    };
679
680    if session.global.dry_run {
681        let _ = writeln!(
682            err,
683            "dry run: would write profile `{profile_name}` (account={}, org={}, {:?}{}{}) to {}",
684            profile.account,
685            profile.org_id,
686            profile.org_kind,
687            profile
688                .description
689                .as_deref()
690                .map_or_else(String::new, |note| format!(", {note}")),
691            if make_default { ", default" } else { "" },
692            session.config_file.display(),
693        );
694        return ExitCode::Success;
695    }
696
697    match store::upsert(
698        &session.config_file,
699        &account,
700        None,
701        Some((&profile_name, &profile)),
702        make_default,
703    ) {
704        Ok(_) => {
705            let _ = writeln!(
706                err,
707                "wrote profile `{profile_name}` to {}{}",
708                session.config_file.display(),
709                if make_default { " (default)" } else { "" },
710            );
711            let _ = writeln!(err, "try it: ytcli auth status --active-only");
712            emit(&format!("{profile_name}\n"));
713            ExitCode::Success
714        }
715        Err(error) => report(&error, ExitCode::Failure),
716    }
717}
718
719/// Who is logging in, where, and with what — everything settled before anything
720/// is written.
721struct Identity {
722    account: String,
723    token: String,
724    org_id: Option<String>,
725    /// The organisation flavour that answered, once verified.
726    org_kind: Option<OrgKind>,
727}
728
729/// Collect and check the credentials.
730///
731/// Flags win; a terminal fills the gaps; outside one, a gap is an error rather
732/// than a prompt nobody will see.
733async fn identity(
734    args: &LoginArgs,
735    session: &Session,
736    interactive: bool,
737) -> Result<Identity, ExitCode> {
738    let mut err = anstream::stderr();
739
740    let account = match args.account.clone() {
741        Some(account) => account,
742        None if interactive => {
743            let existing: Vec<String> = session.config.accounts.keys().cloned().collect();
744            wizard::account(&existing).map_err(|error| report(&error, error.exit_code()))?
745        }
746        None => {
747            return Err(report(
748                &"--account is required when not running in a terminal",
749                ExitCode::ConfirmationRequired,
750            ));
751        }
752    };
753
754    let token = read_token(&account, interactive)?;
755
756    // The organisation decides whether a profile can be written at all, so it is
757    // asked for rather than skipped when someone is there to answer.
758    let (org_id, org_kind) = match (&args.org_id, interactive) {
759        (Some(org_id), _) => (Some(org_id.clone()), args.org_kind),
760        (None, true) => wizard::organisation()
761            .map(|(id, kind)| (Some(id), kind))
762            .map_err(|error| report(&error, error.exit_code()))?,
763        (None, false) => (None, None),
764    };
765
766    let verified = match (&org_id, args.no_verify) {
767        (Some(org_id), false) => {
768            let (kind, who) = verify(&token, org_id, org_kind).await?;
769            let _ = writeln!(err, "verified as {who} in org {org_id} ({kind:?})");
770            Some(kind)
771        }
772        (Some(_), true) => Some(org_kind.unwrap_or(OrgKind::Cloud)),
773        (None, _) => None,
774    };
775
776    Ok(Identity {
777        account,
778        token,
779        org_id,
780        org_kind: verified,
781    })
782}
783
784/// What the profile is being built from, once identity is settled.
785struct Shape<'a> {
786    account: &'a str,
787    token: &'a str,
788    org_id: &'a str,
789    org_kind: OrgKind,
790    interactive: bool,
791}
792
793/// Decide the profile's name, its queue and whether it becomes the default.
794///
795/// Split out so each half of login stays readable: this one asks questions and
796/// touches nothing.
797async fn shape_profile(
798    args: &LoginArgs,
799    session: &Session,
800    shape: &Shape<'_>,
801) -> Result<(String, Profile, bool), ExitCode> {
802    let profile_name = match args.profile.clone() {
803        Some(name) => name,
804        None if shape.interactive => {
805            wizard::profile(shape.account).map_err(|error| report(&error, error.exit_code()))?
806        }
807        None => shape.account.to_owned(),
808    };
809
810    // Offer the queues this token can actually see. Verifying first is what makes
811    // that possible, and turns a spelling test into a choice.
812    let queue = match args.queue.clone() {
813        Some(queue) => Some(queue),
814        None if shape.interactive => {
815            let available = queue_keys(shape.token, shape.org_id, shape.org_kind).await;
816
817            // Listing them anyway makes recording them free, and a collision
818            // with an existing profile can then be caught on the next command
819            // rather than after acting on the wrong issue.
820            if !session.global.dry_run {
821                let cache_path = crate::config::cache::path_for(&session.config_file);
822                let mut cache = crate::config::cache::Cache::load(&cache_path);
823                cache.record(&profile_name, &available);
824                cache.save(&cache_path);
825            }
826
827            wizard::queue(&available).map_err(|error| report(&error, error.exit_code()))?
828        }
829        None => None,
830    };
831
832    // Kept when a re-login does not mention it: the note is about the
833    // organisation, which has not changed just because the token was renewed.
834    let existing = session
835        .config
836        .profiles
837        .get(&profile_name)
838        .and_then(|profile| profile.description.clone());
839    let description = match (args.description.clone(), shape.interactive) {
840        (Some(text), _) => Some(text),
841        (None, true) => wizard::description(existing.as_deref())
842            .map_err(|error| report(&error, error.exit_code()))?
843            .or(existing),
844        (None, false) => existing,
845    };
846
847    let current_default = session.config.default_profile.as_deref();
848    let make_default = if args.default || current_default.is_none() {
849        true
850    } else if shape.interactive {
851        wizard::make_default(&profile_name, current_default)
852            .map_err(|error| report(&error, error.exit_code()))?
853    } else {
854        false
855    };
856
857    Ok((
858        profile_name,
859        Profile {
860            account: shape.account.to_owned(),
861            org_id: shape.org_id.to_owned(),
862            org_kind: shape.org_kind,
863            description,
864            default_queue: queue,
865            display: crate::config::Display::default(),
866        },
867        make_default,
868    ))
869}
870
871/// Queue keys this token can see, for the picker. Best-effort: failing to list
872/// them costs a dropdown, not the login.
873async fn queue_keys(token: &str, org_id: &str, kind: OrgKind) -> Vec<String> {
874    let mut config = ClientConfig::new(token.to_owned(), org_id.to_owned(), kind);
875    if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
876        config.base_url = base;
877    }
878
879    let Ok(client) = Client::new(&config) else {
880        return Vec::new();
881    };
882
883    client.queues().await.map_or_else(
884        |_| Vec::new(),
885        |queues| queues.into_iter().map(|queue| queue.key).collect(),
886    )
887}
888
889/// Read the token: a hidden prompt when someone is typing, stdin when piped.
890fn read_token(account: &str, interactive: bool) -> Result<String, ExitCode> {
891    if interactive {
892        return wizard::token(account).map_err(|error| report(&error, error.exit_code()));
893    }
894
895    let mut piped = String::new();
896    std::io::Read::read_to_string(&mut std::io::stdin(), &mut piped)
897        .map_err(|error| report(&error, ExitCode::Failure))?;
898
899    let token = piped.trim().to_owned();
900    if token.is_empty() {
901        return Err(report(&"no token given", ExitCode::Auth));
902    }
903    Ok(token)
904}
905
906/// Check the token against the API, working out which organisation header it
907/// needs if that was not said.
908///
909/// The two header forms are not interchangeable and the wrong one answers 403,
910/// which reads like a permissions problem rather than a configuration mistake.
911/// Trying both here is one extra request, once, against an afternoon of
912/// confusion later.
913async fn verify(
914    token: &str,
915    org_id: &str,
916    kind: Option<OrgKind>,
917) -> Result<(OrgKind, String), ExitCode> {
918    let candidates: Vec<OrgKind> = match kind {
919        Some(kind) => vec![kind],
920        None => vec![OrgKind::Cloud, OrgKind::Yandex360],
921    };
922
923    let mut last: Option<crate::api::error::ApiError> = None;
924
925    for candidate in candidates {
926        let mut config = ClientConfig::new(token.to_owned(), org_id.to_owned(), candidate);
927        if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
928            config.base_url = base;
929        }
930
931        let client = match Client::new(&config) {
932            Ok(client) => client,
933            Err(error) => {
934                let code = error.exit_code();
935                return Err(report(&error, code));
936            }
937        };
938
939        match client.myself().await {
940            Ok(user) => {
941                let who = user.login.or(user.display).unwrap_or(user.id);
942                return Ok((candidate, who));
943            }
944            // A rejected token is rejected under either header; only an
945            // organisation mismatch is worth retrying the other way.
946            Err(error @ crate::api::error::ApiError::Unauthorized) => {
947                let code = error.exit_code();
948                let reported = report(&error, code);
949                let mut err = anstream::stderr();
950                let _ = writeln!(err, "\n{}", guidance::block(guidance::TOKEN));
951                return Err(reported);
952            }
953            Err(error) => last = Some(error),
954        }
955    }
956
957    let error = last.unwrap_or(crate::api::error::ApiError::Forbidden);
958    let code = error.exit_code();
959    let reported = report(
960        &format!("{error} — checked both organisation header forms"),
961        code,
962    );
963    let mut err = anstream::stderr();
964    let _ = writeln!(err, "\n{}", guidance::block(guidance::ORG));
965    Err(reported)
966}
967
968/// "That name is not in the config, and here are the ones that are."
969///
970/// The list matters more than the refusal: the usual cause is a typo or a
971/// profile from another machine, and both are answered by seeing the names.
972fn unknown<'a>(what: &str, name: &str, configured: impl Iterator<Item = &'a String>) -> ExitCode {
973    let known: Vec<&str> = configured.map(String::as_str).collect();
974    report(
975        &format!(
976            "no {what} called `{name}`; configured: {}",
977            if known.is_empty() {
978                "none — run `ytcli auth login`".to_owned()
979            } else {
980                known.join(", ")
981            }
982        ),
983        ExitCode::NotFound,
984    )
985}
986
987/// Point `default_profile` at another profile.
988///
989/// A local edit and nothing else: no token is read, no request is made. The
990/// profile has to exist, because a default naming a profile that does not is a
991/// config every later command fails on with a worse message than this one.
992fn use_profile(session: &Session, profile: &str) -> ExitCode {
993    let mut err = anstream::stderr();
994
995    if !session.config.profiles.contains_key(profile) {
996        return unknown("profile", profile, session.config.profiles.keys());
997    }
998
999    let previous = session.config.default_profile.clone();
1000    if previous.as_deref() == Some(profile) {
1001        let _ = writeln!(err, "`{profile}` is already the default profile");
1002        return ExitCode::Success;
1003    }
1004
1005    if session.global.dry_run {
1006        let _ = writeln!(
1007            err,
1008            "dry run: would make `{profile}` the default profile in {}",
1009            session.config_file.display()
1010        );
1011        return ExitCode::Success;
1012    }
1013
1014    match store::set_default(&session.config_file, profile) {
1015        Ok(_) => {
1016            let _ = writeln!(
1017                err,
1018                "default profile: {} → {profile}",
1019                previous.as_deref().unwrap_or("none"),
1020            );
1021            ExitCode::Success
1022        }
1023        Err(error) => report(&error, ExitCode::Failure),
1024    }
1025}
1026
1027/// Change an existing profile.
1028///
1029/// Like `auth use`, a local edit: no token is read and no request is made, so a
1030/// profile can be corrected whether or not its credentials currently work. What
1031/// is not passed is not touched — the point of the command is changing one
1032/// thing without restating a profile that already works.
1033fn edit(args: &EditArgs, session: &Session) -> ExitCode {
1034    let mut err = anstream::stderr();
1035
1036    if !session.config.profiles.contains_key(&args.profile) {
1037        return unknown("profile", &args.profile, session.config.profiles.keys());
1038    }
1039
1040    // An account nobody has logged into is a profile that fails on every later
1041    // command, with a message about the account rather than about this edit.
1042    if let Some(account) = args
1043        .account
1044        .as_deref()
1045        .filter(|account| !session.config.accounts.contains_key(*account))
1046    {
1047        return unknown("account", account, session.config.accounts.keys());
1048    }
1049
1050    // An empty string is how a shell says "nothing", so it means the same as
1051    // --clear-description rather than writing a note nobody can read.
1052    let description = if args.clear_description {
1053        Some(None)
1054    } else {
1055        args.description
1056            .as_deref()
1057            .map(str::trim)
1058            .map(|text| (!text.is_empty()).then_some(text))
1059    };
1060
1061    let edits = store::Edits {
1062        name: args.name.as_deref(),
1063        account: args.account.as_deref(),
1064        org_id: args.org_id.as_deref(),
1065        org_kind: args.org_kind,
1066        description,
1067        default_queue: if args.clear_queue {
1068            Some(None)
1069        } else {
1070            args.queue.as_deref().map(Some)
1071        },
1072    };
1073
1074    if edits.is_empty() {
1075        return report(
1076            &format!(
1077                "nothing to change; pass --name, --description, --account, --org-id, --org-kind or --queue (see `ytcli auth edit --help`)\ncurrently: {}",
1078                describe_current(session, &args.profile)
1079            ),
1080            ExitCode::ConfirmationRequired,
1081        );
1082    }
1083
1084    if session.global.dry_run {
1085        let _ = writeln!(
1086            err,
1087            "dry run: would change profile `{}` in {}",
1088            args.profile,
1089            session.config_file.display()
1090        );
1091        return ExitCode::Success;
1092    }
1093
1094    match store::edit(&session.config_file, &args.profile, &edits) {
1095        Ok(_) => {
1096            let name = args.name.as_deref().unwrap_or(&args.profile);
1097            if let Some(new_name) = args.name.as_deref().filter(|name| *name != args.profile) {
1098                rename_side_effects(session, &args.profile, new_name, &mut err);
1099            }
1100            let _ = writeln!(
1101                err,
1102                "profile `{name}`: {}",
1103                describe_after(session, &args.profile, &edits)
1104            );
1105            if args.org_id.is_some() || args.org_kind.is_some() || args.account.is_some() {
1106                let _ = writeln!(
1107                    err,
1108                    "check it: ytcli auth status --profile {name} --active-only"
1109                );
1110            }
1111            emit(&format!("{name}\n"));
1112            ExitCode::Success
1113        }
1114        Err(error) => {
1115            let code = match error {
1116                store::EditError::Unknown(_) => ExitCode::NotFound,
1117                // Neither is ApiRejected: nothing was sent. A name already in
1118                // use, and a file that will not parse, are both plain failures
1119                // of this local edit.
1120                store::EditError::NameTaken(_) | store::EditError::Store(_) => ExitCode::Failure,
1121            };
1122            report(&error, code)
1123        }
1124    }
1125}
1126
1127/// Delete a profile.
1128///
1129/// The counterpart to `auth login`, and deliberately not the counterpart to
1130/// `auth logout`: logout forgets a credential, this forgets an organisation
1131/// someone was reaching through one. The token stays in the keychain, because
1132/// one account usually backs several profiles.
1133///
1134/// `--yes` is required even for one profile. Nothing here is sent anywhere, but
1135/// the `[profiles.x]` table carries display settings and pinned custom fields
1136/// that only exist in this file, and re-logging in does not bring them back.
1137fn remove(session: &Session, profile: &str) -> ExitCode {
1138    let mut err = anstream::stderr();
1139
1140    let Some(current) = session.config.profiles.get(profile) else {
1141        return unknown("profile", profile, session.config.profiles.keys());
1142    };
1143
1144    // The same promise every write makes: say which organisation this is about
1145    // before touching it. Here it matters more than usual — profile names are
1146    // short and similar, and organisation ids are what actually differ.
1147    let about = format!(
1148        "account={} org={} ({:?})",
1149        current.account, current.org_id, current.org_kind
1150    );
1151
1152    if session.global.dry_run {
1153        let _ = writeln!(
1154            err,
1155            "dry run: would remove profile `{profile}` ({about}) from {}",
1156            session.config_file.display()
1157        );
1158        return ExitCode::Success;
1159    }
1160
1161    if !session.global.yes {
1162        let _ = writeln!(
1163            err,
1164            "refusing to remove profile `{profile}` ({about}) without --yes: \
1165             its display settings and pinned fields live only in {}",
1166            session.config_file.display()
1167        );
1168        return ExitCode::ConfirmationRequired;
1169    }
1170
1171    let account = current.account.clone();
1172
1173    match store::remove(&session.config_file, profile) {
1174        Ok(removed) => {
1175            let _ = writeln!(err, "removed profile `{profile}` ({about})");
1176            removal_side_effects(
1177                session,
1178                profile,
1179                &account,
1180                removed.cleared_default,
1181                &mut err,
1182            );
1183            emit(&format!("{profile}\n"));
1184            ExitCode::Success
1185        }
1186        Err(error) => {
1187            let code = match error {
1188                store::EditError::Unknown(_) => ExitCode::NotFound,
1189                store::EditError::NameTaken(_) | store::EditError::Store(_) => ExitCode::Failure,
1190            };
1191            report(&error, code)
1192        }
1193    }
1194}
1195
1196/// Everything outside the profile table that a removal leaves dangling.
1197///
1198/// Each of these is something the user would otherwise meet later, as a failure
1199/// with a worse message than this one.
1200fn removal_side_effects(
1201    session: &Session,
1202    profile: &str,
1203    account: &str,
1204    cleared_default: bool,
1205    err: &mut impl std::io::Write,
1206) {
1207    let cache_path = crate::config::cache::path_for(&session.config_file);
1208    let mut cache = crate::config::cache::Cache::load(&cache_path);
1209    if cache.forget(profile) {
1210        cache.save(&cache_path);
1211    }
1212
1213    if cleared_default {
1214        let remaining: Vec<&str> = session
1215            .config
1216            .profiles
1217            .keys()
1218            .map(String::as_str)
1219            .filter(|name| *name != profile)
1220            .collect();
1221        let _ = writeln!(err, "default profile: {profile} → none");
1222        match remaining.as_slice() {
1223            [] => {
1224                let _ = writeln!(err, "no profiles left; `ytcli auth login` makes another");
1225            }
1226            [only] => {
1227                let _ = writeln!(err, "pick the next one: ytcli auth use {only}");
1228            }
1229            names => {
1230                let _ = writeln!(
1231                    err,
1232                    "pick the next one: ytcli auth use <{}>",
1233                    names.join("|")
1234                );
1235            }
1236        }
1237    }
1238
1239    // The credential outlives the profile on purpose; saying so is what keeps
1240    // "I deleted it" from meaning two different things.
1241    let still_used = session
1242        .config
1243        .profiles
1244        .iter()
1245        .any(|(name, other)| name != profile && other.account == account);
1246    if !still_used && secrets::is_stored(account) {
1247        let _ = writeln!(
1248            err,
1249            "note: account `{account}` still holds a token; ytcli auth logout --account {account} forgets it"
1250        );
1251    }
1252
1253    // Committed and shared with other checkouts, so it is reported rather than
1254    // rewritten — the same rule a rename follows.
1255    if let Some((path, _)) =
1256        crate::config::paths::find_project_pin(&std::env::current_dir().unwrap_or_default())
1257            .filter(|(_, pin)| pin.profile.as_deref() == Some(profile))
1258    {
1259        let _ = writeln!(
1260            err,
1261            "note: {} still names `{profile}`; update it by hand",
1262            path.display()
1263        );
1264    }
1265}
1266
1267/// Carry a rename through the things outside the profile table that name it,
1268/// and say what a local edit cannot reach.
1269fn rename_side_effects(session: &Session, from: &str, to: &str, err: &mut impl std::io::Write) {
1270    let cache_path = crate::config::cache::path_for(&session.config_file);
1271    let mut cache = crate::config::cache::Cache::load(&cache_path);
1272    if cache.rename(from, to) {
1273        cache.save(&cache_path);
1274    }
1275
1276    let _ = writeln!(err, "renamed profile `{from}` → `{to}`");
1277
1278    // A committed `.tracker.toml` is shared with other people and other
1279    // checkouts; rewriting it from here would change what a colleague's next
1280    // command does, so it is reported instead.
1281    if let Some((path, _)) =
1282        crate::config::paths::find_project_pin(&std::env::current_dir().unwrap_or_default())
1283            .filter(|(_, pin)| pin.profile.as_deref() == Some(from))
1284    {
1285        let _ = writeln!(
1286            err,
1287            "note: {} still names `{from}`; update it by hand",
1288            path.display()
1289        );
1290    }
1291
1292    if session.config.default_profile.as_deref() == Some(from) {
1293        let _ = writeln!(err, "default profile: {from} → {to}");
1294    }
1295}
1296
1297/// The profile as it stands, for the message that says nothing was asked for.
1298fn describe_current(session: &Session, profile: &str) -> String {
1299    session
1300        .config
1301        .profiles
1302        .get(profile)
1303        .map_or_else(String::new, |current| {
1304            format!(
1305                "account={} org={} ({:?}) queue={} description={}",
1306                current.account,
1307                current.org_id,
1308                current.org_kind,
1309                current.default_queue.as_deref().unwrap_or("-"),
1310                current.description.as_deref().unwrap_or("-"),
1311            )
1312        })
1313}
1314
1315/// What this edit changed, named key by key so the line is about the change and
1316/// not about the profile.
1317fn describe_after(session: &Session, profile: &str, edits: &store::Edits<'_>) -> String {
1318    let current = session.config.profiles.get(profile);
1319    let mut parts: Vec<String> = Vec::new();
1320
1321    if let Some(account) = edits.account {
1322        parts.push(format!("account={account}"));
1323    }
1324    if let Some(org_id) = edits.org_id {
1325        parts.push(format!("org={org_id}"));
1326    }
1327    if let Some(org_kind) = edits.org_kind {
1328        parts.push(format!("org_kind={org_kind:?}"));
1329    }
1330    match edits.default_queue {
1331        Some(Some(queue)) => parts.push(format!("queue={queue}")),
1332        Some(None) => parts.push("queue removed".to_owned()),
1333        None => {}
1334    }
1335    match edits.description {
1336        Some(Some(text)) => parts.push(format!("description=\"{text}\"")),
1337        Some(None) => parts.push("description removed".to_owned()),
1338        None => {}
1339    }
1340
1341    if parts.is_empty() {
1342        // A rename on its own: say what the profile is now, since its identity
1343        // is exactly what just changed.
1344        return current.map_or_else(String::new, |current| {
1345            format!("account={} org={}", current.account, current.org_id)
1346        });
1347    }
1348
1349    parts.join(" ")
1350}
1351
1352fn logout(account: &str) -> ExitCode {
1353    match secrets::forget(account) {
1354        Ok(()) => {
1355            let mut err = anstream::stderr();
1356            let _ = writeln!(err, "forgot the token for `{account}`");
1357            ExitCode::Success
1358        }
1359        Err(error) => report(&error, ExitCode::Auth),
1360    }
1361}
1362
1363/// Accounts and the profiles pointing at them.
1364///
1365/// Whether a token exists is shown; the token never is.
1366fn list(session: &Session) -> ExitCode {
1367    let mut out = String::with_capacity(256);
1368
1369    let active = session
1370        .resolved
1371        .as_ref()
1372        .map(|resolved| resolved.name.clone());
1373
1374    for (name, account) in &session.config.accounts {
1375        let _ = writeln!(
1376            out,
1377            "account {name}  token: {}  {}",
1378            if secrets::is_stored(name) {
1379                "stored"
1380            } else {
1381                "missing"
1382            },
1383            account.description.as_deref().unwrap_or(""),
1384        );
1385    }
1386
1387    for (name, profile) in &session.config.profiles {
1388        let marks = [
1389            (session.config.default_profile.as_deref() == Some(name.as_str())).then_some("default"),
1390            (active.as_deref() == Some(name.as_str())).then_some("active"),
1391        ];
1392        let marks: Vec<&str> = marks.into_iter().flatten().collect();
1393        let suffix = if marks.is_empty() {
1394            String::new()
1395        } else {
1396            format!("  [{}]", marks.join(", "))
1397        };
1398
1399        let note = profile
1400            .description
1401            .as_deref()
1402            .map_or_else(String::new, |description| format!("  {description}"));
1403
1404        let _ = writeln!(
1405            out,
1406            "profile {name}  account: {}  org: {} ({:?}){suffix}{note}",
1407            profile.account, profile.org_id, profile.org_kind,
1408        );
1409    }
1410
1411    if out.is_empty() {
1412        return report(
1413            &"no accounts or profiles configured yet; see `ytcli auth login --help`",
1414            ExitCode::Auth,
1415        );
1416    }
1417
1418    emit(&out);
1419    ExitCode::Success
1420}