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 describe_profile(profile, paint, &mut out);
246
247 let code = report_profile(
248 profile,
249 brief,
250 paint,
251 name,
252 &mut queues_seen,
253 &mut out,
254 &mut err,
255 )
256 .await;
257 if code == ExitCode::Success {
258 any_success = true;
259 } else {
260 last_failure = Some(code);
261 if is_active {
262 active_failure = Some(code);
263 }
264 }
265 }
266
267 remember_queues(session, brief, active_only, active.as_deref(), &queues_seen);
268 warn_about_collisions(session, paint, &queues_seen);
269
270 if secrets::overridden() && session.config.profiles.len() > 1 {
275 let _ = writeln!(
276 err,
277 "{} YTCLI_TOKEN is set, so every profile above was read through that one token, whatever account it names",
278 paint.paint("warning:", Palette::warn()),
279 );
280 }
281
282 if session.config.profiles.len() > 1 {
285 let _ = writeln!(
286 err,
287 "{}",
288 paint.paint(
289 "change the default with: ytcli auth use <profile>",
290 Palette::label()
291 )
292 );
293 }
294
295 active_failure
299 .or_else(|| (!any_success).then_some(last_failure).flatten())
300 .unwrap_or(ExitCode::Success)
301}
302
303fn describe_profile(
305 profile: &crate::config::Profile,
306 paint: Painter,
307 out: &mut impl std::io::Write,
308) {
309 if let Some(description) = profile.description.as_deref() {
310 let _ = writeln!(
311 out,
312 " {} {description}",
313 paint.paint("note:", Palette::label()),
314 );
315 }
316
317 let _ = writeln!(
318 out,
319 " {} {} {} {} ({:?}) {} {}",
320 paint.paint("account:", Palette::label()),
321 profile.account,
322 paint.paint("org:", Palette::label()),
323 profile.org_id,
324 profile.org_kind,
325 paint.paint("queue:", Palette::label()),
326 profile.default_queue.as_deref().unwrap_or("-"),
327 );
328}
329
330fn remember_queues(
332 session: &Session,
333 brief: bool,
334 active_only: bool,
335 active: Option<&str>,
336 queues_seen: &std::collections::BTreeMap<String, Vec<String>>,
337) {
338 if brief {
339 return;
340 }
341
342 let cache_path = crate::config::cache::path_for(&session.config_file);
343 let mut cache = crate::config::cache::Cache::load(&cache_path);
344
345 for name in session
346 .config
347 .profiles
348 .keys()
349 .filter(|name| !active_only || active == Some(name.as_str()))
350 {
351 let keys: Vec<String> = queues_seen
352 .iter()
353 .filter(|(_, profiles)| profiles.iter().any(|profile| profile == name))
354 .map(|(key, _)| key.clone())
355 .collect();
356 cache.record(name, &keys);
357 }
358
359 cache.save(&cache_path);
360}
361
362fn report_sources(session: &Session, paint: Painter, out: &mut impl std::io::Write) {
373 let from = match std::env::var("YTCLI_CONFIG") {
374 Ok(path) if session.config_file == std::path::Path::new(&path) => "from YTCLI_CONFIG",
375 _ if session.global.config.is_some() => "from --config",
376 _ => "default location",
377 };
378
379 let _ = writeln!(
380 out,
381 "{} {} ({})",
382 paint.paint("config:", Palette::label()),
383 session.config_file.display(),
384 paint.paint(from, Palette::label()),
385 );
386
387 let mut overriding: Vec<String> = std::env::vars()
391 .map(|(name, _)| name)
392 .filter(|name| name.starts_with("YTCLI_") && !name.is_empty())
393 .collect();
394 overriding.sort();
395
396 if !overriding.is_empty() {
397 let _ = writeln!(
398 out,
399 "{} {}",
400 paint.paint("environment:", Palette::label()),
401 overriding.join(", "),
402 );
403 }
404}
405
406fn warn_about_collisions(
416 session: &Session,
417 paint: Painter,
418 queues_seen: &std::collections::BTreeMap<String, Vec<String>>,
419) {
420 let mut err = anstream::stderr();
421
422 let organisation = |name: &str| {
423 session
424 .config
425 .profiles
426 .get(name)
427 .map(|profile| profile.org_id.clone())
428 };
429
430 let ambiguous: Vec<(&String, &Vec<String>)> = queues_seen
431 .iter()
432 .filter(|(_, profiles)| {
433 profiles.len() > 1
434 && profiles
435 .iter()
436 .filter_map(|name| organisation(name))
437 .collect::<std::collections::BTreeSet<_>>()
438 .len()
439 > 1
440 })
441 .collect();
442 if ambiguous.is_empty() {
443 return;
444 }
445
446 let _ = writeln!(err);
447 for (key, profiles) in ambiguous {
448 let _ = writeln!(
449 err,
450 "{} queue {key} is visible in {} — in different organisations, so a bare {key}-1 will be refused; write {}/{key}-1",
451 paint.paint("warning:", Palette::warn()),
452 profiles.join(" and "),
453 profiles.first().map_or("profile", String::as_str),
454 );
455 }
456}
457
458async fn report_profile(
460 profile: &crate::config::Profile,
461 brief: bool,
462 paint: Painter,
463 profile_name: &str,
464 queues_seen: &mut std::collections::BTreeMap<String, Vec<String>>,
465 out: &mut impl std::io::Write,
466 err: &mut impl std::io::Write,
467) -> ExitCode {
468 let (token, origin) = match secrets::token_from(&profile.account) {
469 Ok(pair) => pair,
470 Err(error) => {
471 let _ = writeln!(
472 out,
473 " {} {}",
474 paint.paint("token:", Palette::label()),
475 paint.paint("missing", Palette::bad())
476 );
477 let _ = writeln!(err, " {error}");
478 return ExitCode::Auth;
479 }
480 };
481
482 let mut config = ClientConfig::new(token, profile.org_id.clone(), profile.org_kind);
483 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
484 config.base_url = base;
485 }
486 if let Ok(wiki) = std::env::var("YTCLI_WIKI_URL") {
487 config.wiki_url = wiki;
488 }
489 let client = match Client::new(&config) {
490 Ok(client) => client,
491 Err(error) => {
492 let _ = writeln!(err, " {error}");
493 return error.exit_code();
494 }
495 };
496
497 let via = match origin {
501 secrets::Origin::Environment => " (from YTCLI_TOKEN)",
502 secrets::Origin::Keychain => " (from keychain)",
506 };
507
508 match client.myself().await {
509 Ok(user) => {
510 let _ = writeln!(
511 out,
512 " {} {}{via} {} {}{}",
513 paint.paint("token:", Palette::label()),
514 paint.paint("ok", Palette::ok()),
515 paint.paint("user:", Palette::label()),
516 user.login.as_deref().unwrap_or(&user.id),
517 user.display
518 .as_deref()
519 .map_or_else(String::new, |display| format!(" ({display})")),
520 );
521 }
522 Err(error) => {
523 let _ = writeln!(
524 out,
525 " {} {}",
526 paint.paint("token:", Palette::label()),
527 paint.paint("rejected", Palette::bad())
528 );
529 let _ = writeln!(err, " {error}");
530 if matches!(error, crate::api::error::ApiError::Unauthorized) {
531 let _ = writeln!(err, "\n{}", guidance::block(guidance::TOKEN));
532 }
533 return error.exit_code();
534 }
535 }
536
537 if brief {
538 return ExitCode::Success;
539 }
540
541 reach(&client, paint, profile_name, queues_seen, out).await;
542 ExitCode::Success
543}
544
545async fn reach(
550 client: &Client,
551 paint: Painter,
552 profile_name: &str,
553 queues_seen: &mut std::collections::BTreeMap<String, Vec<String>>,
554 out: &mut impl std::io::Write,
555) {
556 let queues = client.queues().await.ok();
557 let projects = client.entities("project", None, 1, 5).await.ok();
558 let goals = client.entities("goal", None, 1, 1).await.ok();
559 let mine = client
560 .count("Assignee: me() AND Resolution: empty()")
561 .await
562 .ok();
563
564 let _ = writeln!(
565 out,
566 " {} {} {} {} {} {} {} {}",
567 paint.paint("queues:", Palette::label()),
568 queues
569 .as_ref()
570 .map_or_else(|| "-".to_owned(), |queues| queues.len().to_string()),
571 paint.paint("projects:", Palette::label()),
572 projects.as_ref().map_or_else(|| "-".to_owned(), count_of),
573 paint.paint("goals:", Palette::label()),
574 goals.as_ref().map_or_else(|| "-".to_owned(), count_of),
575 paint.paint("my open issues:", Palette::label()),
576 mine.map_or_else(|| "-".to_owned(), |count| count.to_string()),
577 );
578
579 if let Some(projects) = projects.filter(|page| !page.items.is_empty()) {
580 let names: Vec<String> = projects
581 .items
582 .iter()
583 .map(|project| {
584 project.short_id.map_or_else(
585 || project.summary.clone(),
586 |id| format!("{} ({id})", project.summary),
587 )
588 })
589 .collect();
590 let more = projects
591 .total
592 .unwrap_or(names.len() as u64)
593 .saturating_sub(names.len() as u64);
594 let suffix = if more > 0 {
595 format!(", +{more} more")
596 } else {
597 String::new()
598 };
599 let _ = writeln!(
600 out,
601 " {} {}{suffix}",
602 paint.paint("projects:", Palette::label()),
603 names.join(", ")
604 );
605 }
606
607 if let Some(queues) = queues.filter(|queues| !queues.is_empty()) {
608 for queue in &queues {
609 queues_seen
610 .entry(queue.key.clone())
611 .or_default()
612 .push(profile_name.to_owned());
613 }
614
615 let keys: Vec<&str> = queues
616 .iter()
617 .take(8)
618 .map(|queue| queue.key.as_str())
619 .collect();
620 let more = queues.len().saturating_sub(keys.len());
621 let suffix = if more > 0 {
622 format!(", +{more} more")
623 } else {
624 String::new()
625 };
626 let _ = writeln!(
627 out,
628 " {} {}{suffix}",
629 paint.paint("queues:", Palette::label()),
630 keys.join(", ")
631 );
632 }
633
634 let wiki = match client.wiki_reachable().await {
637 Ok(()) => paint.paint("ok", Palette::ok()),
638 Err(crate::api::error::ApiError::WikiForbidden) => paint.paint(
639 "no access — the token lacks wiki:read; sign in again with `ytcli auth login`",
640 Palette::warn(),
641 ),
642 Err(crate::api::error::ApiError::WikiNotEnabled) => paint.paint(
643 "not set up in this organisation — open https://wiki.yandex.ru once to start it",
644 Palette::warn(),
645 ),
646 Err(_) => "-".to_owned(),
647 };
648 let _ = writeln!(out, " {} {wiki}", paint.paint("wiki:", Palette::label()));
649}
650
651fn count_of<T>(page: &crate::api::models::Page<T>) -> String {
652 page.total
653 .map_or_else(|| page.items.len().to_string(), |total| total.to_string())
654}
655
656async fn login(args: &LoginArgs, session: &Session) -> ExitCode {
663 let interactive = wizard::is_interactive();
664 let mut err = anstream::stderr();
665
666 if interactive && !oauth::App::is_configured() {
670 wizard::introduce();
671 }
672
673 let Identity {
674 account,
675 token,
676 refresh,
677 org_id,
678 org_kind: verified,
679 } = match identity(args, session, interactive).await {
680 Ok(identity) => identity,
681 Err(code) => return code,
682 };
683
684 if session.global.dry_run {
685 let _ = writeln!(
686 err,
687 "dry run: would store a token for `{account}` in the OS keychain"
688 );
689 } else {
690 if let Err(error) = secrets::store(&account, &token) {
691 return report(&error, ExitCode::Auth);
692 }
693 if let Err(error) = secrets::store_refresh(&account, refresh.as_deref()) {
696 return report(&error, ExitCode::Auth);
697 }
698 let _ = writeln!(
699 err,
700 "stored a token for `{account}` in the OS keychain{}",
701 if refresh.is_some() {
702 ", with what renews it"
703 } else {
704 ""
705 }
706 );
707 }
708
709 let Some(org_id) = org_id else {
710 let _ = writeln!(
711 err,
712 "no --org-id given, so no profile was written and nothing can be queried yet.\n"
713 );
714 let _ = writeln!(err, "{}", guidance::block(guidance::ORG));
715 let _ = writeln!(
716 err,
717 "\nThen: ytcli auth login --account {account} --org-id <id> [--queue <QUEUE>]"
718 );
719 return ExitCode::Success;
720 };
721
722 let org_kind = verified.unwrap_or(OrgKind::Cloud);
723
724 let shape = Shape {
725 account: &account,
726 token: &token,
727 org_id: &org_id,
728 org_kind,
729 interactive,
730 };
731 let (profile_name, profile, make_default) = match shape_profile(args, session, &shape).await {
732 Ok(shaped) => shaped,
733 Err(code) => return code,
734 };
735
736 if session.global.dry_run {
737 let _ = writeln!(
738 err,
739 "dry run: would write profile `{profile_name}` (account={}, org={}, {:?}{}{}) to {}",
740 profile.account,
741 profile.org_id,
742 profile.org_kind,
743 profile
744 .description
745 .as_deref()
746 .map_or_else(String::new, |note| format!(", {note}")),
747 if make_default { ", default" } else { "" },
748 session.config_file.display(),
749 );
750 return ExitCode::Success;
751 }
752
753 match store::upsert(
754 &session.config_file,
755 &account,
756 None,
757 Some((&profile_name, &profile)),
758 make_default,
759 ) {
760 Ok(_) => {
761 let _ = writeln!(
762 err,
763 "wrote profile `{profile_name}` to {}{}",
764 session.config_file.display(),
765 if make_default { " (default)" } else { "" },
766 );
767 let _ = writeln!(err, "try it: ytcli auth status --active-only");
768 emit(&format!("{profile_name}\n"));
769 ExitCode::Success
770 }
771 Err(error) => report(&error, ExitCode::Failure),
772 }
773}
774
775struct Identity {
778 account: String,
779 token: String,
780 refresh: Option<String>,
782 org_id: Option<String>,
783 org_kind: Option<OrgKind>,
785}
786
787async fn identity(
792 args: &LoginArgs,
793 session: &Session,
794 interactive: bool,
795) -> Result<Identity, ExitCode> {
796 let mut err = anstream::stderr();
797
798 let account = match args.account.clone() {
799 Some(account) => account,
800 None if interactive => {
801 let existing: Vec<String> = session.config.accounts.keys().cloned().collect();
802 wizard::account(&existing).map_err(|error| report(&error, error.exit_code()))?
803 }
804 None => {
805 return Err(report(
806 &"--account is required when not running in a terminal",
807 ExitCode::ConfirmationRequired,
808 ));
809 }
810 };
811
812 let (token, refresh) = obtain_token(args, &account, interactive).await?;
813
814 let (org_id, org_kind) = match (&args.org_id, interactive) {
817 (Some(org_id), _) => (Some(org_id.clone()), args.org_kind),
818 (None, true) => wizard::organisation()
819 .map(|(id, kind)| (Some(id), kind))
820 .map_err(|error| report(&error, error.exit_code()))?,
821 (None, false) => (None, None),
822 };
823
824 let verified = match (&org_id, args.no_verify) {
825 (Some(org_id), false) => {
826 let (kind, who) = verify(&token, org_id, org_kind).await?;
827 let _ = writeln!(err, "verified as {who} in org {org_id} ({kind:?})");
828 Some(kind)
829 }
830 (Some(_), true) => Some(org_kind.unwrap_or(OrgKind::Cloud)),
831 (None, _) => None,
832 };
833
834 Ok(Identity {
835 account,
836 token,
837 refresh,
838 org_id,
839 org_kind: verified,
840 })
841}
842
843async fn obtain_token(
849 args: &LoginArgs,
850 account: &str,
851 interactive: bool,
852) -> Result<(String, Option<String>), ExitCode> {
853 let configured = oauth::App::is_configured();
854 let browser = args.device
855 || (interactive
856 && configured
857 && wizard::sign_in_in_browser().map_err(|error| report(&error, error.exit_code()))?);
858
859 if browser {
860 let grant = sign_in(args.read_only, interactive).await?;
861 if interactive && args.org_id.is_none() {
862 let mut err = anstream::stderr();
863 let _ = writeln!(err, "\n{}", guidance::block(guidance::ORG));
864 }
865 return Ok((grant.access_token, grant.refresh_token));
866 }
867
868 if interactive && configured {
869 wizard::introduce();
870 }
871 read_token(account, interactive).map(|token| (token, None))
872}
873
874async fn sign_in(read_only: bool, interactive: bool) -> Result<oauth::Grant, ExitCode> {
876 let fail = |error: oauth::OAuthError| report(&error, error.exit_code());
877 let mut err = anstream::stderr();
878
879 let app = oauth::App::from_environment().map_err(fail)?;
880 let code = app
881 .request_code(read_only.then_some(oauth::READ_ONLY_SCOPE))
882 .await
883 .map_err(fail)?;
884
885 let paint = Painter::for_stream(std::io::IsTerminal::is_terminal(&std::io::stderr()));
889 let expires = code.expires_in.map_or_else(String::new, |seconds| {
890 format!(" (expires in {} min)", seconds.div_ceil(60))
891 });
892 let _ = writeln!(
893 err,
894 "\n{}\n\n 1. Copy the code {}\n 2. Open the page {}{}\n 3. Paste the code there and allow access for ytcli\n\n {}\n",
895 paint.paint("Sign in with Yandex", Palette::heading()),
896 paint.paint(&code.user_code, Palette::key()),
897 paint.link(&code.verification_url),
898 paint.paint(&expires, Palette::label()),
899 paint.paint(
902 "Only confirm a code you started here yourself.",
903 Palette::label()
904 ),
905 );
906
907 let early = if interactive {
908 wizard::press_enter("Press Enter to open the page in your browser… ")
909 .map_err(|error| report(&error, error.exit_code()))?;
910 let early = app.try_grant(&code).await.map_err(fail)?;
913 if early.is_none() {
914 open_browser(&code.verification_url);
915 }
916 early
917 } else {
918 None
919 };
920
921 let grant = if let Some(grant) = early {
922 grant
923 } else {
924 let _ = writeln!(err, "waiting for the code to be confirmed…");
925 app.await_grant(&code).await.map_err(fail)?
926 };
927 let _ = writeln!(
928 err,
929 "signed in{}",
930 if grant.refresh_token.is_some() {
931 "; renew later with `ytcli auth refresh`"
932 } else {
933 ""
934 }
935 );
936 Ok(grant)
937}
938
939fn open_browser(url: &str) {
946 let plain = ["https://ya.ru/", "https://oauth.yandex."]
947 .iter()
948 .any(|prefix| url.starts_with(prefix))
949 && url
950 .bytes()
951 .all(|byte| byte.is_ascii_alphanumeric() || b":/.-_".contains(&byte));
952 if !plain {
953 return;
954 }
955
956 let mut command = if cfg!(target_os = "macos") {
957 std::process::Command::new("open")
958 } else if cfg!(windows) {
959 let mut command = std::process::Command::new("cmd");
960 command.args(["/C", "start", ""]);
961 command
962 } else {
963 std::process::Command::new("xdg-open")
964 };
965 let _ = command
966 .arg(url)
967 .stdin(std::process::Stdio::null())
968 .stdout(std::process::Stdio::null())
969 .stderr(std::process::Stdio::null())
970 .spawn();
971}
972
973async fn refresh(session: &Session, account: Option<&str>) -> ExitCode {
978 let mut err = anstream::stderr();
979
980 let Some(account) = account.map(ToOwned::to_owned).or_else(|| {
981 session
982 .resolved
983 .as_ref()
984 .map(|resolved| resolved.profile.account.clone())
985 }) else {
986 return report(
987 &"no --account given, and no active profile to take one from",
988 ExitCode::Auth,
989 );
990 };
991
992 let using: Vec<String> = session
995 .config
996 .profiles
997 .iter()
998 .filter(|(_, profile)| profile.account == account)
999 .map(|(name, profile)| format!("{name} (org {})", profile.org_id))
1000 .collect();
1001 let _ = writeln!(
1002 err,
1003 "renewing the token of account `{account}`, used by: {}",
1004 if using.is_empty() {
1005 "no profile".to_owned()
1006 } else {
1007 using.join(", ")
1008 }
1009 );
1010
1011 let refresh_token = match secrets::refresh_token(&account) {
1012 Ok(Some(token)) => token,
1013 Ok(None) => {
1014 return report(
1015 &format!(
1016 "`{account}` has nothing to renew it with: its token was pasted, not signed in for. \
1017 Run `ytcli auth login --account {account}`"
1018 ),
1019 ExitCode::Auth,
1020 );
1021 }
1022 Err(error) => return report(&error, ExitCode::Auth),
1023 };
1024
1025 if session.global.dry_run {
1026 let _ = writeln!(
1027 err,
1028 "dry run: would exchange the refresh token of `{account}` for a new token"
1029 );
1030 return ExitCode::Success;
1031 }
1032
1033 let grant = match oauth::App::from_environment() {
1034 Ok(app) => app.refresh(&refresh_token).await,
1035 Err(error) => Err(error),
1036 };
1037 let grant = match grant {
1038 Ok(grant) => grant,
1039 Err(error) => return report(&error, error.exit_code()),
1040 };
1041
1042 let unchanged = secrets::token(&account).is_ok_and(|current| current == grant.access_token);
1043 if let Err(error) = secrets::store(&account, &grant.access_token) {
1044 return report(&error, ExitCode::Auth);
1045 }
1046 let renews = grant.refresh_token.as_deref().unwrap_or(&refresh_token);
1047 if let Err(error) = secrets::store_refresh(&account, Some(renews)) {
1048 return report(&error, ExitCode::Auth);
1049 }
1050
1051 if unchanged {
1052 let _ = writeln!(
1053 err,
1054 "Yandex kept the same token for `{account}`: it has long enough left to run"
1055 );
1056 } else {
1057 let _ = writeln!(err, "stored a renewed token for `{account}`");
1058 }
1059 ExitCode::Success
1060}
1061
1062struct Shape<'a> {
1064 account: &'a str,
1065 token: &'a str,
1066 org_id: &'a str,
1067 org_kind: OrgKind,
1068 interactive: bool,
1069}
1070
1071async fn shape_profile(
1076 args: &LoginArgs,
1077 session: &Session,
1078 shape: &Shape<'_>,
1079) -> Result<(String, Profile, bool), ExitCode> {
1080 let profile_name = match args.profile.clone() {
1081 Some(name) => name,
1082 None if shape.interactive => {
1083 wizard::profile(shape.account).map_err(|error| report(&error, error.exit_code()))?
1084 }
1085 None => shape.account.to_owned(),
1086 };
1087
1088 let queue = match args.queue.clone() {
1091 Some(queue) => Some(queue),
1092 None if shape.interactive => {
1093 let available = queue_keys(shape.token, shape.org_id, shape.org_kind).await;
1094
1095 if !session.global.dry_run {
1099 let cache_path = crate::config::cache::path_for(&session.config_file);
1100 let mut cache = crate::config::cache::Cache::load(&cache_path);
1101 cache.record(&profile_name, &available);
1102 cache.save(&cache_path);
1103 }
1104
1105 wizard::queue(&available).map_err(|error| report(&error, error.exit_code()))?
1106 }
1107 None => None,
1108 };
1109
1110 let existing = session
1113 .config
1114 .profiles
1115 .get(&profile_name)
1116 .and_then(|profile| profile.description.clone());
1117 let description = match (args.description.clone(), shape.interactive) {
1118 (Some(text), _) => Some(text),
1119 (None, true) => wizard::description(existing.as_deref())
1120 .map_err(|error| report(&error, error.exit_code()))?
1121 .or(existing),
1122 (None, false) => existing,
1123 };
1124
1125 let current_default = session.config.default_profile.as_deref();
1126 let make_default = if args.default || current_default.is_none() {
1127 true
1128 } else if shape.interactive {
1129 wizard::make_default(&profile_name, current_default)
1130 .map_err(|error| report(&error, error.exit_code()))?
1131 } else {
1132 false
1133 };
1134
1135 Ok((
1136 profile_name,
1137 Profile {
1138 account: shape.account.to_owned(),
1139 org_id: shape.org_id.to_owned(),
1140 org_kind: shape.org_kind,
1141 description,
1142 default_queue: queue,
1143 display: crate::config::Display::default(),
1144 },
1145 make_default,
1146 ))
1147}
1148
1149async fn queue_keys(token: &str, org_id: &str, kind: OrgKind) -> Vec<String> {
1152 let mut config = ClientConfig::new(token.to_owned(), org_id.to_owned(), kind);
1153 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
1154 config.base_url = base;
1155 }
1156
1157 let Ok(client) = Client::new(&config) else {
1158 return Vec::new();
1159 };
1160
1161 client.queues().await.map_or_else(
1162 |_| Vec::new(),
1163 |queues| queues.into_iter().map(|queue| queue.key).collect(),
1164 )
1165}
1166
1167fn read_token(account: &str, interactive: bool) -> Result<String, ExitCode> {
1169 if interactive {
1170 return wizard::token(account).map_err(|error| report(&error, error.exit_code()));
1171 }
1172
1173 let mut piped = String::new();
1174 std::io::Read::read_to_string(&mut std::io::stdin(), &mut piped)
1175 .map_err(|error| report(&error, ExitCode::Failure))?;
1176
1177 let token = piped.trim().to_owned();
1178 if token.is_empty() {
1179 return Err(report(&"no token given", ExitCode::Auth));
1180 }
1181 Ok(token)
1182}
1183
1184async fn verify(
1192 token: &str,
1193 org_id: &str,
1194 kind: Option<OrgKind>,
1195) -> Result<(OrgKind, String), ExitCode> {
1196 let candidates: Vec<OrgKind> = match kind {
1197 Some(kind) => vec![kind],
1198 None => vec![OrgKind::Cloud, OrgKind::Yandex360],
1199 };
1200
1201 let mut last: Option<crate::api::error::ApiError> = None;
1202
1203 for candidate in candidates {
1204 let mut config = ClientConfig::new(token.to_owned(), org_id.to_owned(), candidate);
1205 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
1206 config.base_url = base;
1207 }
1208
1209 let client = match Client::new(&config) {
1210 Ok(client) => client,
1211 Err(error) => {
1212 let code = error.exit_code();
1213 return Err(report(&error, code));
1214 }
1215 };
1216
1217 match client.myself().await {
1218 Ok(user) => {
1219 let who = user.login.or(user.display).unwrap_or(user.id);
1220 return Ok((candidate, who));
1221 }
1222 Err(error @ crate::api::error::ApiError::Unauthorized) => {
1225 let code = error.exit_code();
1226 let reported = report(&error, code);
1227 let mut err = anstream::stderr();
1228 let _ = writeln!(err, "\n{}", guidance::block(guidance::TOKEN));
1229 return Err(reported);
1230 }
1231 Err(error) => last = Some(error),
1232 }
1233 }
1234
1235 let error = last.unwrap_or(crate::api::error::ApiError::Forbidden);
1236 let code = error.exit_code();
1237 let reported = report(
1238 &format!("{error} — checked both organisation header forms"),
1239 code,
1240 );
1241 let mut err = anstream::stderr();
1242 let _ = writeln!(err, "\n{}", guidance::block(guidance::ORG));
1243 Err(reported)
1244}
1245
1246fn unknown<'a>(what: &str, name: &str, configured: impl Iterator<Item = &'a String>) -> ExitCode {
1251 let known: Vec<&str> = configured.map(String::as_str).collect();
1252 report(
1253 &format!(
1254 "no {what} called `{name}`; configured: {}",
1255 if known.is_empty() {
1256 "none — run `ytcli auth login`".to_owned()
1257 } else {
1258 known.join(", ")
1259 }
1260 ),
1261 ExitCode::NotFound,
1262 )
1263}
1264
1265fn use_profile(session: &Session, profile: &str) -> ExitCode {
1271 let mut err = anstream::stderr();
1272
1273 if !session.config.profiles.contains_key(profile) {
1274 return unknown("profile", profile, session.config.profiles.keys());
1275 }
1276
1277 let previous = session.config.default_profile.clone();
1278 if previous.as_deref() == Some(profile) {
1279 let _ = writeln!(err, "`{profile}` is already the default profile");
1280 return ExitCode::Success;
1281 }
1282
1283 if session.global.dry_run {
1284 let _ = writeln!(
1285 err,
1286 "dry run: would make `{profile}` the default profile in {}",
1287 session.config_file.display()
1288 );
1289 return ExitCode::Success;
1290 }
1291
1292 match store::set_default(&session.config_file, profile) {
1293 Ok(_) => {
1294 let _ = writeln!(
1295 err,
1296 "default profile: {} → {profile}",
1297 previous.as_deref().unwrap_or("none"),
1298 );
1299 ExitCode::Success
1300 }
1301 Err(error) => report(&error, ExitCode::Failure),
1302 }
1303}
1304
1305fn edit(args: &EditArgs, session: &Session) -> ExitCode {
1312 let mut err = anstream::stderr();
1313
1314 if !session.config.profiles.contains_key(&args.profile) {
1315 return unknown("profile", &args.profile, session.config.profiles.keys());
1316 }
1317
1318 if let Some(account) = args
1321 .account
1322 .as_deref()
1323 .filter(|account| !session.config.accounts.contains_key(*account))
1324 {
1325 return unknown("account", account, session.config.accounts.keys());
1326 }
1327
1328 let description = if args.clear_description {
1331 Some(None)
1332 } else {
1333 args.description
1334 .as_deref()
1335 .map(str::trim)
1336 .map(|text| (!text.is_empty()).then_some(text))
1337 };
1338
1339 let edits = store::Edits {
1340 name: args.name.as_deref(),
1341 account: args.account.as_deref(),
1342 org_id: args.org_id.as_deref(),
1343 org_kind: args.org_kind,
1344 description,
1345 default_queue: if args.clear_queue {
1346 Some(None)
1347 } else {
1348 args.queue.as_deref().map(Some)
1349 },
1350 };
1351
1352 if edits.is_empty() {
1353 return report(
1354 &format!(
1355 "nothing to change; pass --name, --description, --account, --org-id, --org-kind or --queue (see `ytcli auth edit --help`)\ncurrently: {}",
1356 describe_current(session, &args.profile)
1357 ),
1358 ExitCode::ConfirmationRequired,
1359 );
1360 }
1361
1362 if session.global.dry_run {
1363 let _ = writeln!(
1364 err,
1365 "dry run: would change profile `{}` in {}",
1366 args.profile,
1367 session.config_file.display()
1368 );
1369 return ExitCode::Success;
1370 }
1371
1372 match store::edit(&session.config_file, &args.profile, &edits) {
1373 Ok(_) => {
1374 let name = args.name.as_deref().unwrap_or(&args.profile);
1375 if let Some(new_name) = args.name.as_deref().filter(|name| *name != args.profile) {
1376 rename_side_effects(session, &args.profile, new_name, &mut err);
1377 }
1378 let _ = writeln!(
1379 err,
1380 "profile `{name}`: {}",
1381 describe_after(session, &args.profile, &edits)
1382 );
1383 if args.org_id.is_some() || args.org_kind.is_some() || args.account.is_some() {
1384 let _ = writeln!(
1385 err,
1386 "check it: ytcli auth status --profile {name} --active-only"
1387 );
1388 }
1389 emit(&format!("{name}\n"));
1390 ExitCode::Success
1391 }
1392 Err(error) => {
1393 let code = match error {
1394 store::EditError::Unknown(_) => ExitCode::NotFound,
1395 store::EditError::NameTaken(_) | store::EditError::Store(_) => ExitCode::Failure,
1399 };
1400 report(&error, code)
1401 }
1402 }
1403}
1404
1405fn remove(session: &Session, profile: &str) -> ExitCode {
1416 let mut err = anstream::stderr();
1417
1418 let Some(current) = session.config.profiles.get(profile) else {
1419 return unknown("profile", profile, session.config.profiles.keys());
1420 };
1421
1422 let about = format!(
1426 "account={} org={} ({:?})",
1427 current.account, current.org_id, current.org_kind
1428 );
1429
1430 if session.global.dry_run {
1431 let _ = writeln!(
1432 err,
1433 "dry run: would remove profile `{profile}` ({about}) from {}",
1434 session.config_file.display()
1435 );
1436 return ExitCode::Success;
1437 }
1438
1439 if !session.global.yes {
1440 let _ = writeln!(
1441 err,
1442 "refusing to remove profile `{profile}` ({about}) without --yes: \
1443 its display settings and pinned fields live only in {}",
1444 session.config_file.display()
1445 );
1446 return ExitCode::ConfirmationRequired;
1447 }
1448
1449 let account = current.account.clone();
1450
1451 match store::remove(&session.config_file, profile) {
1452 Ok(removed) => {
1453 let _ = writeln!(err, "removed profile `{profile}` ({about})");
1454 removal_side_effects(
1455 session,
1456 profile,
1457 &account,
1458 removed.cleared_default,
1459 &mut err,
1460 );
1461 emit(&format!("{profile}\n"));
1462 ExitCode::Success
1463 }
1464 Err(error) => {
1465 let code = match error {
1466 store::EditError::Unknown(_) => ExitCode::NotFound,
1467 store::EditError::NameTaken(_) | store::EditError::Store(_) => ExitCode::Failure,
1468 };
1469 report(&error, code)
1470 }
1471 }
1472}
1473
1474fn removal_side_effects(
1479 session: &Session,
1480 profile: &str,
1481 account: &str,
1482 cleared_default: bool,
1483 err: &mut impl std::io::Write,
1484) {
1485 let cache_path = crate::config::cache::path_for(&session.config_file);
1486 let mut cache = crate::config::cache::Cache::load(&cache_path);
1487 if cache.forget(profile) {
1488 cache.save(&cache_path);
1489 }
1490
1491 if cleared_default {
1492 let remaining: Vec<&str> = session
1493 .config
1494 .profiles
1495 .keys()
1496 .map(String::as_str)
1497 .filter(|name| *name != profile)
1498 .collect();
1499 let _ = writeln!(err, "default profile: {profile} → none");
1500 match remaining.as_slice() {
1501 [] => {
1502 let _ = writeln!(err, "no profiles left; `ytcli auth login` makes another");
1503 }
1504 [only] => {
1505 let _ = writeln!(err, "pick the next one: ytcli auth use {only}");
1506 }
1507 names => {
1508 let _ = writeln!(
1509 err,
1510 "pick the next one: ytcli auth use <{}>",
1511 names.join("|")
1512 );
1513 }
1514 }
1515 }
1516
1517 let still_used = session
1520 .config
1521 .profiles
1522 .iter()
1523 .any(|(name, other)| name != profile && other.account == account);
1524 if !still_used && secrets::is_stored(account) {
1525 let _ = writeln!(
1526 err,
1527 "note: account `{account}` still holds a token; ytcli auth logout --account {account} forgets it"
1528 );
1529 }
1530
1531 if let Some((path, _)) =
1534 crate::config::paths::find_project_pin(&std::env::current_dir().unwrap_or_default())
1535 .filter(|(_, pin)| pin.profile.as_deref() == Some(profile))
1536 {
1537 let _ = writeln!(
1538 err,
1539 "note: {} still names `{profile}`; update it by hand",
1540 path.display()
1541 );
1542 }
1543}
1544
1545fn rename_side_effects(session: &Session, from: &str, to: &str, err: &mut impl std::io::Write) {
1548 let cache_path = crate::config::cache::path_for(&session.config_file);
1549 let mut cache = crate::config::cache::Cache::load(&cache_path);
1550 if cache.rename(from, to) {
1551 cache.save(&cache_path);
1552 }
1553
1554 let _ = writeln!(err, "renamed profile `{from}` → `{to}`");
1555
1556 if let Some((path, _)) =
1560 crate::config::paths::find_project_pin(&std::env::current_dir().unwrap_or_default())
1561 .filter(|(_, pin)| pin.profile.as_deref() == Some(from))
1562 {
1563 let _ = writeln!(
1564 err,
1565 "note: {} still names `{from}`; update it by hand",
1566 path.display()
1567 );
1568 }
1569
1570 if session.config.default_profile.as_deref() == Some(from) {
1571 let _ = writeln!(err, "default profile: {from} → {to}");
1572 }
1573}
1574
1575fn describe_current(session: &Session, profile: &str) -> String {
1577 session
1578 .config
1579 .profiles
1580 .get(profile)
1581 .map_or_else(String::new, |current| {
1582 format!(
1583 "account={} org={} ({:?}) queue={} description={}",
1584 current.account,
1585 current.org_id,
1586 current.org_kind,
1587 current.default_queue.as_deref().unwrap_or("-"),
1588 current.description.as_deref().unwrap_or("-"),
1589 )
1590 })
1591}
1592
1593fn describe_after(session: &Session, profile: &str, edits: &store::Edits<'_>) -> String {
1596 let current = session.config.profiles.get(profile);
1597 let mut parts: Vec<String> = Vec::new();
1598
1599 if let Some(account) = edits.account {
1600 parts.push(format!("account={account}"));
1601 }
1602 if let Some(org_id) = edits.org_id {
1603 parts.push(format!("org={org_id}"));
1604 }
1605 if let Some(org_kind) = edits.org_kind {
1606 parts.push(format!("org_kind={org_kind:?}"));
1607 }
1608 match edits.default_queue {
1609 Some(Some(queue)) => parts.push(format!("queue={queue}")),
1610 Some(None) => parts.push("queue removed".to_owned()),
1611 None => {}
1612 }
1613 match edits.description {
1614 Some(Some(text)) => parts.push(format!("description=\"{text}\"")),
1615 Some(None) => parts.push("description removed".to_owned()),
1616 None => {}
1617 }
1618
1619 if parts.is_empty() {
1620 return current.map_or_else(String::new, |current| {
1623 format!("account={} org={}", current.account, current.org_id)
1624 });
1625 }
1626
1627 parts.join(" ")
1628}
1629
1630fn logout(account: &str) -> ExitCode {
1631 match secrets::forget(account) {
1632 Ok(()) => {
1633 let mut err = anstream::stderr();
1634 let _ = writeln!(err, "forgot the token for `{account}`");
1635 ExitCode::Success
1636 }
1637 Err(error) => report(&error, ExitCode::Auth),
1638 }
1639}
1640
1641fn list(session: &Session) -> ExitCode {
1645 let mut out = String::with_capacity(256);
1646
1647 let active = session
1648 .resolved
1649 .as_ref()
1650 .map(|resolved| resolved.name.clone());
1651
1652 for (name, account) in &session.config.accounts {
1653 let _ = writeln!(
1654 out,
1655 "account {name} token: {} {}",
1656 if secrets::is_stored(name) {
1657 "stored"
1658 } else {
1659 "missing"
1660 },
1661 account.description.as_deref().unwrap_or(""),
1662 );
1663 }
1664
1665 for (name, profile) in &session.config.profiles {
1666 let marks = [
1667 (session.config.default_profile.as_deref() == Some(name.as_str())).then_some("default"),
1668 (active.as_deref() == Some(name.as_str())).then_some("active"),
1669 ];
1670 let marks: Vec<&str> = marks.into_iter().flatten().collect();
1671 let suffix = if marks.is_empty() {
1672 String::new()
1673 } else {
1674 format!(" [{}]", marks.join(", "))
1675 };
1676
1677 let note = profile
1678 .description
1679 .as_deref()
1680 .map_or_else(String::new, |description| format!(" {description}"));
1681
1682 let _ = writeln!(
1683 out,
1684 "profile {name} account: {} org: {} ({:?}){suffix}{note}",
1685 profile.account, profile.org_id, profile.org_kind,
1686 );
1687 }
1688
1689 if out.is_empty() {
1690 return report(
1691 &"no accounts or profiles configured yet; see `ytcli auth login --help`",
1692 ExitCode::Auth,
1693 );
1694 }
1695
1696 emit(&out);
1697 ExitCode::Success
1698}