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