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::render::style::{Painter, Palette};
18use crate::secrets;
19
20#[derive(Debug, Subcommand)]
21pub enum AuthCommand {
22 #[command(long_about = crate::cli::guidance::login_help())]
24 Login(LoginArgs),
25 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_LOGOUT))]
27 Logout {
28 #[arg(long, short = 'a')]
29 account: String,
30 },
31 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_LIST))]
33 List,
34 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_USE))]
36 Use {
37 profile: String,
39 },
40 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_EDIT))]
42 Edit(EditArgs),
43 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_STATUS))]
45 Status {
46 #[arg(long)]
48 brief: bool,
49 #[arg(long)]
51 active_only: bool,
52 },
53}
54
55#[derive(Debug, Args)]
61pub struct LoginArgs {
62 #[arg(long, short = 'a')]
64 pub account: Option<String>,
65
66 #[arg(long)]
68 pub org_id: Option<String>,
69
70 #[arg(long, value_enum)]
72 pub org_kind: Option<OrgKind>,
73
74 #[arg(long, short = 'p')]
76 pub profile: Option<String>,
77
78 #[arg(long, short = 'q')]
80 pub queue: Option<String>,
81
82 #[arg(long)]
86 pub description: Option<String>,
87
88 #[arg(long)]
90 pub default: bool,
91
92 #[arg(long)]
94 pub no_verify: bool,
95}
96
97#[derive(Debug, Args)]
103pub struct EditArgs {
104 pub profile: String,
106
107 #[arg(long)]
109 pub name: Option<String>,
110
111 #[arg(long)]
113 pub description: Option<String>,
114
115 #[arg(long, conflicts_with = "description")]
117 pub clear_description: bool,
118
119 #[arg(long, short = 'a')]
121 pub account: Option<String>,
122
123 #[arg(long)]
125 pub org_id: Option<String>,
126
127 #[arg(long, value_enum)]
129 pub org_kind: Option<OrgKind>,
130
131 #[arg(long, short = 'q')]
133 pub queue: Option<String>,
134
135 #[arg(long, conflicts_with = "queue")]
137 pub clear_queue: bool,
138}
139
140pub 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
152async 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 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 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 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 active_failure
270 .or_else(|| (!any_success).then_some(last_failure).flatten())
271 .unwrap_or(ExitCode::Success)
272}
273
274fn 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
301fn 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
333fn 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 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
377fn 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
429async 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 let via = match origin {
469 secrets::Origin::Environment => " (from YTCLI_TOKEN)",
470 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
513async 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
608async fn login(args: &LoginArgs, session: &Session) -> ExitCode {
615 let interactive = wizard::is_interactive();
616 let mut err = anstream::stderr();
617
618 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
712struct Identity {
715 account: String,
716 token: String,
717 org_id: Option<String>,
718 org_kind: Option<OrgKind>,
720}
721
722async 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 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
777struct Shape<'a> {
779 account: &'a str,
780 token: &'a str,
781 org_id: &'a str,
782 org_kind: OrgKind,
783 interactive: bool,
784}
785
786async 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 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 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 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
864async 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
882fn 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
899async 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 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
961fn 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
980fn 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
1020fn 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 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 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 store::EditError::NameTaken(_) | store::EditError::Store(_) => ExitCode::Failure,
1114 };
1115 report(&error, code)
1116 }
1117 }
1118}
1119
1120fn 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 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
1150fn 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
1168fn 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 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
1216fn 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}