Skip to main content

ytcli/cli/
auth.rs

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