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