1use 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 #[command(long_about = crate::cli::guidance::login_help())]
25 Login(LoginArgs),
26 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_REFRESH))]
28 Refresh {
29 #[arg(long, short = 'a')]
31 account: Option<String>,
32 },
33 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_LOGOUT))]
35 Logout {
36 #[arg(long, short = 'a')]
37 account: String,
38 },
39 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_LIST))]
41 List,
42 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_USE))]
44 Use {
45 profile: String,
47 },
48 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_EDIT))]
50 Edit(EditArgs),
51 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_REMOVE))]
53 Remove {
54 profile: String,
56 },
57 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_STATUS))]
59 Status {
60 #[arg(long)]
62 brief: bool,
63 #[arg(long)]
65 active_only: bool,
66 },
67}
68
69#[allow(clippy::struct_excessive_bools)]
77#[derive(Debug, Args)]
78pub struct LoginArgs {
79 #[arg(long, short = 'a')]
81 pub account: Option<String>,
82
83 #[arg(long)]
85 pub org_id: Option<String>,
86
87 #[arg(long, value_enum)]
89 pub org_kind: Option<OrgKind>,
90
91 #[arg(long, short = 'p')]
93 pub profile: Option<String>,
94
95 #[arg(long, short = 'q')]
97 pub queue: Option<String>,
98
99 #[arg(long)]
103 pub description: Option<String>,
104
105 #[arg(long)]
107 pub default: bool,
108
109 #[arg(long)]
111 pub no_verify: bool,
112
113 #[arg(long)]
116 pub device: bool,
117
118 #[arg(long)]
121 pub read_only: bool,
122}
123
124#[derive(Debug, Args)]
130pub struct EditArgs {
131 pub profile: String,
133
134 #[arg(long)]
136 pub name: Option<String>,
137
138 #[arg(long)]
140 pub description: Option<String>,
141
142 #[arg(long, conflicts_with = "description")]
144 pub clear_description: bool,
145
146 #[arg(long, short = 'a')]
148 pub account: Option<String>,
149
150 #[arg(long)]
152 pub org_id: Option<String>,
153
154 #[arg(long, value_enum)]
156 pub org_kind: Option<OrgKind>,
157
158 #[arg(long, short = 'q')]
160 pub queue: Option<String>,
161
162 #[arg(long, conflicts_with = "queue")]
164 pub clear_queue: bool,
165}
166
167pub 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
181async 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 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 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 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 active_failure
304 .or_else(|| (!any_success).then_some(last_failure).flatten())
305 .unwrap_or(ExitCode::Success)
306}
307
308fn 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
338fn 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
349fn 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
381fn 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 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
425fn 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
477async 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 let via = match origin {
520 secrets::Origin::Environment => " (from YTCLI_TOKEN)",
521 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
564async 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 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
675async fn login(args: &LoginArgs, session: &Session) -> ExitCode {
682 let interactive = wizard::is_interactive();
683 let mut err = anstream::stderr();
684
685 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 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
784fn 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
808struct Identity {
811 account: String,
812 token: String,
813 refresh: Option<String>,
815 access: Option<crate::config::Access>,
817 org_id: Option<String>,
818 org_kind: Option<OrgKind>,
820}
821
822async 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 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
879async 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
915async 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 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 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 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
980fn 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
1014async 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 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
1103struct Shape<'a> {
1105 account: &'a str,
1106 token: &'a str,
1107 org_id: &'a str,
1108 org_kind: OrgKind,
1109 interactive: bool,
1110}
1111
1112async 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 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 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 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
1190async 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
1208fn 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
1225async 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 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
1287fn 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
1306fn 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
1346fn 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 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 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 store::EditError::NameTaken(_) | store::EditError::Store(_) => ExitCode::Failure,
1440 };
1441 report(&error, code)
1442 }
1443 }
1444}
1445
1446fn 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 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
1515fn 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 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 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
1586fn 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 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
1616fn 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
1634fn 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 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
1682fn 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 #[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}