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_REMOVE))]
45 Remove {
46 profile: String,
48 },
49 #[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_STATUS))]
51 Status {
52 #[arg(long)]
54 brief: bool,
55 #[arg(long)]
57 active_only: bool,
58 },
59}
60
61#[derive(Debug, Args)]
67pub struct LoginArgs {
68 #[arg(long, short = 'a')]
70 pub account: Option<String>,
71
72 #[arg(long)]
74 pub org_id: Option<String>,
75
76 #[arg(long, value_enum)]
78 pub org_kind: Option<OrgKind>,
79
80 #[arg(long, short = 'p')]
82 pub profile: Option<String>,
83
84 #[arg(long, short = 'q')]
86 pub queue: Option<String>,
87
88 #[arg(long)]
92 pub description: Option<String>,
93
94 #[arg(long)]
96 pub default: bool,
97
98 #[arg(long)]
100 pub no_verify: bool,
101}
102
103#[derive(Debug, Args)]
109pub struct EditArgs {
110 pub profile: String,
112
113 #[arg(long)]
115 pub name: Option<String>,
116
117 #[arg(long)]
119 pub description: Option<String>,
120
121 #[arg(long, conflicts_with = "description")]
123 pub clear_description: bool,
124
125 #[arg(long, short = 'a')]
127 pub account: Option<String>,
128
129 #[arg(long)]
131 pub org_id: Option<String>,
132
133 #[arg(long, value_enum)]
135 pub org_kind: Option<OrgKind>,
136
137 #[arg(long, short = 'q')]
139 pub queue: Option<String>,
140
141 #[arg(long, conflicts_with = "queue")]
143 pub clear_queue: bool,
144}
145
146pub async fn run(command: &AuthCommand, session: &Session) -> ExitCode {
148 match command {
149 AuthCommand::Status { brief, active_only } => status(session, *brief, *active_only).await,
150 AuthCommand::Login(args) => login(args, session).await,
151 AuthCommand::Logout { account } => logout(account),
152 AuthCommand::List => list(session),
153 AuthCommand::Use { profile } => use_profile(session, profile),
154 AuthCommand::Edit(args) => edit(args, session),
155 AuthCommand::Remove { profile } => remove(session, profile),
156 }
157}
158
159async fn status(session: &Session, brief: bool, active_only: bool) -> ExitCode {
170 let mut out = anstream::stdout();
171 let mut err = anstream::stderr();
172 let paint = session.render.painter();
173
174 if session.config.profiles.is_empty() {
175 let _ = writeln!(err, "no profiles configured yet.\n");
176 let _ = writeln!(err, "{}", guidance::full());
177 let _ = writeln!(
178 err,
179 "Then: ytcli auth login --account <name> --org-id <id> [--queue <QUEUE>]"
180 );
181 return ExitCode::Auth;
182 }
183
184 report_sources(session, paint, &mut out);
185
186 let active = session
187 .resolved
188 .as_ref()
189 .map(|resolved| resolved.name.clone());
190 let mut active_failure = None;
191 let mut any_success = false;
192 let mut last_failure = None;
193 let mut queues_seen: std::collections::BTreeMap<String, Vec<String>> =
195 std::collections::BTreeMap::new();
196
197 for (name, profile) in &session.config.profiles {
198 let is_active = active.as_deref() == Some(name.as_str());
199 if active_only && !is_active {
200 continue;
201 }
202
203 let source = if is_active {
204 session
205 .resolved
206 .as_ref()
207 .map_or_else(String::new, |resolved| {
208 format!(" (from {})", resolved.source)
209 })
210 } else {
211 String::new()
212 };
213 let marks = if is_active { " [active]" } else { "" };
214
215 let _ = writeln!(
216 out,
217 "{} {}{}{}",
218 paint.paint("profile", Palette::label()),
219 paint.paint(name, Palette::key()),
220 paint.paint(&source, Palette::label()),
221 paint.paint(marks, Palette::ok()),
222 );
223 describe_profile(profile, paint, &mut out);
224
225 let code = report_profile(
226 profile,
227 brief,
228 paint,
229 name,
230 &mut queues_seen,
231 &mut out,
232 &mut err,
233 )
234 .await;
235 if code == ExitCode::Success {
236 any_success = true;
237 } else {
238 last_failure = Some(code);
239 if is_active {
240 active_failure = Some(code);
241 }
242 }
243 }
244
245 remember_queues(session, brief, active_only, active.as_deref(), &queues_seen);
246 warn_about_collisions(session, paint, &queues_seen);
247
248 if secrets::overridden() && session.config.profiles.len() > 1 {
253 let _ = writeln!(
254 err,
255 "{} YTCLI_TOKEN is set, so every profile above was read through that one token, whatever account it names",
256 paint.paint("warning:", Palette::warn()),
257 );
258 }
259
260 if session.config.profiles.len() > 1 {
263 let _ = writeln!(
264 err,
265 "{}",
266 paint.paint(
267 "change the default with: ytcli auth use <profile>",
268 Palette::label()
269 )
270 );
271 }
272
273 active_failure
277 .or_else(|| (!any_success).then_some(last_failure).flatten())
278 .unwrap_or(ExitCode::Success)
279}
280
281fn describe_profile(
283 profile: &crate::config::Profile,
284 paint: Painter,
285 out: &mut impl std::io::Write,
286) {
287 if let Some(description) = profile.description.as_deref() {
288 let _ = writeln!(
289 out,
290 " {} {description}",
291 paint.paint("note:", Palette::label()),
292 );
293 }
294
295 let _ = writeln!(
296 out,
297 " {} {} {} {} ({:?}) {} {}",
298 paint.paint("account:", Palette::label()),
299 profile.account,
300 paint.paint("org:", Palette::label()),
301 profile.org_id,
302 profile.org_kind,
303 paint.paint("queue:", Palette::label()),
304 profile.default_queue.as_deref().unwrap_or("-"),
305 );
306}
307
308fn remember_queues(
310 session: &Session,
311 brief: bool,
312 active_only: bool,
313 active: Option<&str>,
314 queues_seen: &std::collections::BTreeMap<String, Vec<String>>,
315) {
316 if brief {
317 return;
318 }
319
320 let cache_path = crate::config::cache::path_for(&session.config_file);
321 let mut cache = crate::config::cache::Cache::load(&cache_path);
322
323 for name in session
324 .config
325 .profiles
326 .keys()
327 .filter(|name| !active_only || active == Some(name.as_str()))
328 {
329 let keys: Vec<String> = queues_seen
330 .iter()
331 .filter(|(_, profiles)| profiles.iter().any(|profile| profile == name))
332 .map(|(key, _)| key.clone())
333 .collect();
334 cache.record(name, &keys);
335 }
336
337 cache.save(&cache_path);
338}
339
340fn report_sources(session: &Session, paint: Painter, out: &mut impl std::io::Write) {
351 let from = match std::env::var("YTCLI_CONFIG") {
352 Ok(path) if session.config_file == std::path::Path::new(&path) => "from YTCLI_CONFIG",
353 _ if session.global.config.is_some() => "from --config",
354 _ => "default location",
355 };
356
357 let _ = writeln!(
358 out,
359 "{} {} ({})",
360 paint.paint("config:", Palette::label()),
361 session.config_file.display(),
362 paint.paint(from, Palette::label()),
363 );
364
365 let mut overriding: Vec<String> = std::env::vars()
369 .map(|(name, _)| name)
370 .filter(|name| name.starts_with("YTCLI_") && !name.is_empty())
371 .collect();
372 overriding.sort();
373
374 if !overriding.is_empty() {
375 let _ = writeln!(
376 out,
377 "{} {}",
378 paint.paint("environment:", Palette::label()),
379 overriding.join(", "),
380 );
381 }
382}
383
384fn warn_about_collisions(
394 session: &Session,
395 paint: Painter,
396 queues_seen: &std::collections::BTreeMap<String, Vec<String>>,
397) {
398 let mut err = anstream::stderr();
399
400 let organisation = |name: &str| {
401 session
402 .config
403 .profiles
404 .get(name)
405 .map(|profile| profile.org_id.clone())
406 };
407
408 let ambiguous: Vec<(&String, &Vec<String>)> = queues_seen
409 .iter()
410 .filter(|(_, profiles)| {
411 profiles.len() > 1
412 && profiles
413 .iter()
414 .filter_map(|name| organisation(name))
415 .collect::<std::collections::BTreeSet<_>>()
416 .len()
417 > 1
418 })
419 .collect();
420 if ambiguous.is_empty() {
421 return;
422 }
423
424 let _ = writeln!(err);
425 for (key, profiles) in ambiguous {
426 let _ = writeln!(
427 err,
428 "{} queue {key} is visible in {} — in different organisations, so a bare {key}-1 will be refused; write {}/{key}-1",
429 paint.paint("warning:", Palette::warn()),
430 profiles.join(" and "),
431 profiles.first().map_or("profile", String::as_str),
432 );
433 }
434}
435
436async fn report_profile(
438 profile: &crate::config::Profile,
439 brief: bool,
440 paint: Painter,
441 profile_name: &str,
442 queues_seen: &mut std::collections::BTreeMap<String, Vec<String>>,
443 out: &mut impl std::io::Write,
444 err: &mut impl std::io::Write,
445) -> ExitCode {
446 let (token, origin) = match secrets::token_from(&profile.account) {
447 Ok(pair) => pair,
448 Err(error) => {
449 let _ = writeln!(
450 out,
451 " {} {}",
452 paint.paint("token:", Palette::label()),
453 paint.paint("missing", Palette::bad())
454 );
455 let _ = writeln!(err, " {error}");
456 return ExitCode::Auth;
457 }
458 };
459
460 let mut config = ClientConfig::new(token, profile.org_id.clone(), profile.org_kind);
461 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
462 config.base_url = base;
463 }
464 let client = match Client::new(&config) {
465 Ok(client) => client,
466 Err(error) => {
467 let _ = writeln!(err, " {error}");
468 return error.exit_code();
469 }
470 };
471
472 let via = match origin {
476 secrets::Origin::Environment => " (from YTCLI_TOKEN)",
477 secrets::Origin::Keychain => " (from keychain)",
481 };
482
483 match client.myself().await {
484 Ok(user) => {
485 let _ = writeln!(
486 out,
487 " {} {}{via} {} {}{}",
488 paint.paint("token:", Palette::label()),
489 paint.paint("ok", Palette::ok()),
490 paint.paint("user:", Palette::label()),
491 user.login.as_deref().unwrap_or(&user.id),
492 user.display
493 .as_deref()
494 .map_or_else(String::new, |display| format!(" ({display})")),
495 );
496 }
497 Err(error) => {
498 let _ = writeln!(
499 out,
500 " {} {}",
501 paint.paint("token:", Palette::label()),
502 paint.paint("rejected", Palette::bad())
503 );
504 let _ = writeln!(err, " {error}");
505 if matches!(error, crate::api::error::ApiError::Unauthorized) {
506 let _ = writeln!(err, "\n{}", guidance::block(guidance::TOKEN));
507 }
508 return error.exit_code();
509 }
510 }
511
512 if brief {
513 return ExitCode::Success;
514 }
515
516 reach(&client, paint, profile_name, queues_seen, out).await;
517 ExitCode::Success
518}
519
520async fn reach(
525 client: &Client,
526 paint: Painter,
527 profile_name: &str,
528 queues_seen: &mut std::collections::BTreeMap<String, Vec<String>>,
529 out: &mut impl std::io::Write,
530) {
531 let queues = client.queues().await.ok();
532 let projects = client.entities("project", None, 1, 5).await.ok();
533 let goals = client.entities("goal", None, 1, 1).await.ok();
534 let mine = client
535 .count("Assignee: me() AND Resolution: empty()")
536 .await
537 .ok();
538
539 let _ = writeln!(
540 out,
541 " {} {} {} {} {} {} {} {}",
542 paint.paint("queues:", Palette::label()),
543 queues
544 .as_ref()
545 .map_or_else(|| "-".to_owned(), |queues| queues.len().to_string()),
546 paint.paint("projects:", Palette::label()),
547 projects.as_ref().map_or_else(|| "-".to_owned(), count_of),
548 paint.paint("goals:", Palette::label()),
549 goals.as_ref().map_or_else(|| "-".to_owned(), count_of),
550 paint.paint("my open issues:", Palette::label()),
551 mine.map_or_else(|| "-".to_owned(), |count| count.to_string()),
552 );
553
554 if let Some(projects) = projects.filter(|page| !page.items.is_empty()) {
555 let names: Vec<String> = projects
556 .items
557 .iter()
558 .map(|project| {
559 project.short_id.map_or_else(
560 || project.summary.clone(),
561 |id| format!("{} ({id})", project.summary),
562 )
563 })
564 .collect();
565 let more = projects
566 .total
567 .unwrap_or(names.len() as u64)
568 .saturating_sub(names.len() as u64);
569 let suffix = if more > 0 {
570 format!(", +{more} more")
571 } else {
572 String::new()
573 };
574 let _ = writeln!(
575 out,
576 " {} {}{suffix}",
577 paint.paint("projects:", Palette::label()),
578 names.join(", ")
579 );
580 }
581
582 if let Some(queues) = queues.filter(|queues| !queues.is_empty()) {
583 for queue in &queues {
584 queues_seen
585 .entry(queue.key.clone())
586 .or_default()
587 .push(profile_name.to_owned());
588 }
589
590 let keys: Vec<&str> = queues
591 .iter()
592 .take(8)
593 .map(|queue| queue.key.as_str())
594 .collect();
595 let more = queues.len().saturating_sub(keys.len());
596 let suffix = if more > 0 {
597 format!(", +{more} more")
598 } else {
599 String::new()
600 };
601 let _ = writeln!(
602 out,
603 " {} {}{suffix}",
604 paint.paint("queues:", Palette::label()),
605 keys.join(", ")
606 );
607 }
608}
609
610fn count_of<T>(page: &crate::api::models::Page<T>) -> String {
611 page.total
612 .map_or_else(|| page.items.len().to_string(), |total| total.to_string())
613}
614
615async fn login(args: &LoginArgs, session: &Session) -> ExitCode {
622 let interactive = wizard::is_interactive();
623 let mut err = anstream::stderr();
624
625 if interactive {
628 wizard::introduce();
629 }
630
631 let Identity {
632 account,
633 token,
634 org_id,
635 org_kind: verified,
636 } = match identity(args, session, interactive).await {
637 Ok(identity) => identity,
638 Err(code) => return code,
639 };
640
641 if session.global.dry_run {
642 let _ = writeln!(
643 err,
644 "dry run: would store a token for `{account}` in the OS keychain"
645 );
646 } else {
647 if let Err(error) = secrets::store(&account, &token) {
648 return report(&error, ExitCode::Auth);
649 }
650 let _ = writeln!(err, "stored a token for `{account}` in the OS keychain");
651 }
652
653 let Some(org_id) = org_id else {
654 let _ = writeln!(
655 err,
656 "no --org-id given, so no profile was written and nothing can be queried yet.\n"
657 );
658 let _ = writeln!(err, "{}", guidance::block(guidance::ORG));
659 let _ = writeln!(
660 err,
661 "\nThen: ytcli auth login --account {account} --org-id <id> [--queue <QUEUE>]"
662 );
663 return ExitCode::Success;
664 };
665
666 let org_kind = verified.unwrap_or(OrgKind::Cloud);
667
668 let shape = Shape {
669 account: &account,
670 token: &token,
671 org_id: &org_id,
672 org_kind,
673 interactive,
674 };
675 let (profile_name, profile, make_default) = match shape_profile(args, session, &shape).await {
676 Ok(shaped) => shaped,
677 Err(code) => return code,
678 };
679
680 if session.global.dry_run {
681 let _ = writeln!(
682 err,
683 "dry run: would write profile `{profile_name}` (account={}, org={}, {:?}{}{}) to {}",
684 profile.account,
685 profile.org_id,
686 profile.org_kind,
687 profile
688 .description
689 .as_deref()
690 .map_or_else(String::new, |note| format!(", {note}")),
691 if make_default { ", default" } else { "" },
692 session.config_file.display(),
693 );
694 return ExitCode::Success;
695 }
696
697 match store::upsert(
698 &session.config_file,
699 &account,
700 None,
701 Some((&profile_name, &profile)),
702 make_default,
703 ) {
704 Ok(_) => {
705 let _ = writeln!(
706 err,
707 "wrote profile `{profile_name}` to {}{}",
708 session.config_file.display(),
709 if make_default { " (default)" } else { "" },
710 );
711 let _ = writeln!(err, "try it: ytcli auth status --active-only");
712 emit(&format!("{profile_name}\n"));
713 ExitCode::Success
714 }
715 Err(error) => report(&error, ExitCode::Failure),
716 }
717}
718
719struct Identity {
722 account: String,
723 token: String,
724 org_id: Option<String>,
725 org_kind: Option<OrgKind>,
727}
728
729async fn identity(
734 args: &LoginArgs,
735 session: &Session,
736 interactive: bool,
737) -> Result<Identity, ExitCode> {
738 let mut err = anstream::stderr();
739
740 let account = match args.account.clone() {
741 Some(account) => account,
742 None if interactive => {
743 let existing: Vec<String> = session.config.accounts.keys().cloned().collect();
744 wizard::account(&existing).map_err(|error| report(&error, error.exit_code()))?
745 }
746 None => {
747 return Err(report(
748 &"--account is required when not running in a terminal",
749 ExitCode::ConfirmationRequired,
750 ));
751 }
752 };
753
754 let token = read_token(&account, interactive)?;
755
756 let (org_id, org_kind) = match (&args.org_id, interactive) {
759 (Some(org_id), _) => (Some(org_id.clone()), args.org_kind),
760 (None, true) => wizard::organisation()
761 .map(|(id, kind)| (Some(id), kind))
762 .map_err(|error| report(&error, error.exit_code()))?,
763 (None, false) => (None, None),
764 };
765
766 let verified = match (&org_id, args.no_verify) {
767 (Some(org_id), false) => {
768 let (kind, who) = verify(&token, org_id, org_kind).await?;
769 let _ = writeln!(err, "verified as {who} in org {org_id} ({kind:?})");
770 Some(kind)
771 }
772 (Some(_), true) => Some(org_kind.unwrap_or(OrgKind::Cloud)),
773 (None, _) => None,
774 };
775
776 Ok(Identity {
777 account,
778 token,
779 org_id,
780 org_kind: verified,
781 })
782}
783
784struct Shape<'a> {
786 account: &'a str,
787 token: &'a str,
788 org_id: &'a str,
789 org_kind: OrgKind,
790 interactive: bool,
791}
792
793async fn shape_profile(
798 args: &LoginArgs,
799 session: &Session,
800 shape: &Shape<'_>,
801) -> Result<(String, Profile, bool), ExitCode> {
802 let profile_name = match args.profile.clone() {
803 Some(name) => name,
804 None if shape.interactive => {
805 wizard::profile(shape.account).map_err(|error| report(&error, error.exit_code()))?
806 }
807 None => shape.account.to_owned(),
808 };
809
810 let queue = match args.queue.clone() {
813 Some(queue) => Some(queue),
814 None if shape.interactive => {
815 let available = queue_keys(shape.token, shape.org_id, shape.org_kind).await;
816
817 if !session.global.dry_run {
821 let cache_path = crate::config::cache::path_for(&session.config_file);
822 let mut cache = crate::config::cache::Cache::load(&cache_path);
823 cache.record(&profile_name, &available);
824 cache.save(&cache_path);
825 }
826
827 wizard::queue(&available).map_err(|error| report(&error, error.exit_code()))?
828 }
829 None => None,
830 };
831
832 let existing = session
835 .config
836 .profiles
837 .get(&profile_name)
838 .and_then(|profile| profile.description.clone());
839 let description = match (args.description.clone(), shape.interactive) {
840 (Some(text), _) => Some(text),
841 (None, true) => wizard::description(existing.as_deref())
842 .map_err(|error| report(&error, error.exit_code()))?
843 .or(existing),
844 (None, false) => existing,
845 };
846
847 let current_default = session.config.default_profile.as_deref();
848 let make_default = if args.default || current_default.is_none() {
849 true
850 } else if shape.interactive {
851 wizard::make_default(&profile_name, current_default)
852 .map_err(|error| report(&error, error.exit_code()))?
853 } else {
854 false
855 };
856
857 Ok((
858 profile_name,
859 Profile {
860 account: shape.account.to_owned(),
861 org_id: shape.org_id.to_owned(),
862 org_kind: shape.org_kind,
863 description,
864 default_queue: queue,
865 display: crate::config::Display::default(),
866 },
867 make_default,
868 ))
869}
870
871async fn queue_keys(token: &str, org_id: &str, kind: OrgKind) -> Vec<String> {
874 let mut config = ClientConfig::new(token.to_owned(), org_id.to_owned(), kind);
875 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
876 config.base_url = base;
877 }
878
879 let Ok(client) = Client::new(&config) else {
880 return Vec::new();
881 };
882
883 client.queues().await.map_or_else(
884 |_| Vec::new(),
885 |queues| queues.into_iter().map(|queue| queue.key).collect(),
886 )
887}
888
889fn read_token(account: &str, interactive: bool) -> Result<String, ExitCode> {
891 if interactive {
892 return wizard::token(account).map_err(|error| report(&error, error.exit_code()));
893 }
894
895 let mut piped = String::new();
896 std::io::Read::read_to_string(&mut std::io::stdin(), &mut piped)
897 .map_err(|error| report(&error, ExitCode::Failure))?;
898
899 let token = piped.trim().to_owned();
900 if token.is_empty() {
901 return Err(report(&"no token given", ExitCode::Auth));
902 }
903 Ok(token)
904}
905
906async fn verify(
914 token: &str,
915 org_id: &str,
916 kind: Option<OrgKind>,
917) -> Result<(OrgKind, String), ExitCode> {
918 let candidates: Vec<OrgKind> = match kind {
919 Some(kind) => vec![kind],
920 None => vec![OrgKind::Cloud, OrgKind::Yandex360],
921 };
922
923 let mut last: Option<crate::api::error::ApiError> = None;
924
925 for candidate in candidates {
926 let mut config = ClientConfig::new(token.to_owned(), org_id.to_owned(), candidate);
927 if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
928 config.base_url = base;
929 }
930
931 let client = match Client::new(&config) {
932 Ok(client) => client,
933 Err(error) => {
934 let code = error.exit_code();
935 return Err(report(&error, code));
936 }
937 };
938
939 match client.myself().await {
940 Ok(user) => {
941 let who = user.login.or(user.display).unwrap_or(user.id);
942 return Ok((candidate, who));
943 }
944 Err(error @ crate::api::error::ApiError::Unauthorized) => {
947 let code = error.exit_code();
948 let reported = report(&error, code);
949 let mut err = anstream::stderr();
950 let _ = writeln!(err, "\n{}", guidance::block(guidance::TOKEN));
951 return Err(reported);
952 }
953 Err(error) => last = Some(error),
954 }
955 }
956
957 let error = last.unwrap_or(crate::api::error::ApiError::Forbidden);
958 let code = error.exit_code();
959 let reported = report(
960 &format!("{error} — checked both organisation header forms"),
961 code,
962 );
963 let mut err = anstream::stderr();
964 let _ = writeln!(err, "\n{}", guidance::block(guidance::ORG));
965 Err(reported)
966}
967
968fn unknown<'a>(what: &str, name: &str, configured: impl Iterator<Item = &'a String>) -> ExitCode {
973 let known: Vec<&str> = configured.map(String::as_str).collect();
974 report(
975 &format!(
976 "no {what} called `{name}`; configured: {}",
977 if known.is_empty() {
978 "none — run `ytcli auth login`".to_owned()
979 } else {
980 known.join(", ")
981 }
982 ),
983 ExitCode::NotFound,
984 )
985}
986
987fn use_profile(session: &Session, profile: &str) -> ExitCode {
993 let mut err = anstream::stderr();
994
995 if !session.config.profiles.contains_key(profile) {
996 return unknown("profile", profile, session.config.profiles.keys());
997 }
998
999 let previous = session.config.default_profile.clone();
1000 if previous.as_deref() == Some(profile) {
1001 let _ = writeln!(err, "`{profile}` is already the default profile");
1002 return ExitCode::Success;
1003 }
1004
1005 if session.global.dry_run {
1006 let _ = writeln!(
1007 err,
1008 "dry run: would make `{profile}` the default profile in {}",
1009 session.config_file.display()
1010 );
1011 return ExitCode::Success;
1012 }
1013
1014 match store::set_default(&session.config_file, profile) {
1015 Ok(_) => {
1016 let _ = writeln!(
1017 err,
1018 "default profile: {} → {profile}",
1019 previous.as_deref().unwrap_or("none"),
1020 );
1021 ExitCode::Success
1022 }
1023 Err(error) => report(&error, ExitCode::Failure),
1024 }
1025}
1026
1027fn edit(args: &EditArgs, session: &Session) -> ExitCode {
1034 let mut err = anstream::stderr();
1035
1036 if !session.config.profiles.contains_key(&args.profile) {
1037 return unknown("profile", &args.profile, session.config.profiles.keys());
1038 }
1039
1040 if let Some(account) = args
1043 .account
1044 .as_deref()
1045 .filter(|account| !session.config.accounts.contains_key(*account))
1046 {
1047 return unknown("account", account, session.config.accounts.keys());
1048 }
1049
1050 let description = if args.clear_description {
1053 Some(None)
1054 } else {
1055 args.description
1056 .as_deref()
1057 .map(str::trim)
1058 .map(|text| (!text.is_empty()).then_some(text))
1059 };
1060
1061 let edits = store::Edits {
1062 name: args.name.as_deref(),
1063 account: args.account.as_deref(),
1064 org_id: args.org_id.as_deref(),
1065 org_kind: args.org_kind,
1066 description,
1067 default_queue: if args.clear_queue {
1068 Some(None)
1069 } else {
1070 args.queue.as_deref().map(Some)
1071 },
1072 };
1073
1074 if edits.is_empty() {
1075 return report(
1076 &format!(
1077 "nothing to change; pass --name, --description, --account, --org-id, --org-kind or --queue (see `ytcli auth edit --help`)\ncurrently: {}",
1078 describe_current(session, &args.profile)
1079 ),
1080 ExitCode::ConfirmationRequired,
1081 );
1082 }
1083
1084 if session.global.dry_run {
1085 let _ = writeln!(
1086 err,
1087 "dry run: would change profile `{}` in {}",
1088 args.profile,
1089 session.config_file.display()
1090 );
1091 return ExitCode::Success;
1092 }
1093
1094 match store::edit(&session.config_file, &args.profile, &edits) {
1095 Ok(_) => {
1096 let name = args.name.as_deref().unwrap_or(&args.profile);
1097 if let Some(new_name) = args.name.as_deref().filter(|name| *name != args.profile) {
1098 rename_side_effects(session, &args.profile, new_name, &mut err);
1099 }
1100 let _ = writeln!(
1101 err,
1102 "profile `{name}`: {}",
1103 describe_after(session, &args.profile, &edits)
1104 );
1105 if args.org_id.is_some() || args.org_kind.is_some() || args.account.is_some() {
1106 let _ = writeln!(
1107 err,
1108 "check it: ytcli auth status --profile {name} --active-only"
1109 );
1110 }
1111 emit(&format!("{name}\n"));
1112 ExitCode::Success
1113 }
1114 Err(error) => {
1115 let code = match error {
1116 store::EditError::Unknown(_) => ExitCode::NotFound,
1117 store::EditError::NameTaken(_) | store::EditError::Store(_) => ExitCode::Failure,
1121 };
1122 report(&error, code)
1123 }
1124 }
1125}
1126
1127fn remove(session: &Session, profile: &str) -> ExitCode {
1138 let mut err = anstream::stderr();
1139
1140 let Some(current) = session.config.profiles.get(profile) else {
1141 return unknown("profile", profile, session.config.profiles.keys());
1142 };
1143
1144 let about = format!(
1148 "account={} org={} ({:?})",
1149 current.account, current.org_id, current.org_kind
1150 );
1151
1152 if session.global.dry_run {
1153 let _ = writeln!(
1154 err,
1155 "dry run: would remove profile `{profile}` ({about}) from {}",
1156 session.config_file.display()
1157 );
1158 return ExitCode::Success;
1159 }
1160
1161 if !session.global.yes {
1162 let _ = writeln!(
1163 err,
1164 "refusing to remove profile `{profile}` ({about}) without --yes: \
1165 its display settings and pinned fields live only in {}",
1166 session.config_file.display()
1167 );
1168 return ExitCode::ConfirmationRequired;
1169 }
1170
1171 let account = current.account.clone();
1172
1173 match store::remove(&session.config_file, profile) {
1174 Ok(removed) => {
1175 let _ = writeln!(err, "removed profile `{profile}` ({about})");
1176 removal_side_effects(
1177 session,
1178 profile,
1179 &account,
1180 removed.cleared_default,
1181 &mut err,
1182 );
1183 emit(&format!("{profile}\n"));
1184 ExitCode::Success
1185 }
1186 Err(error) => {
1187 let code = match error {
1188 store::EditError::Unknown(_) => ExitCode::NotFound,
1189 store::EditError::NameTaken(_) | store::EditError::Store(_) => ExitCode::Failure,
1190 };
1191 report(&error, code)
1192 }
1193 }
1194}
1195
1196fn removal_side_effects(
1201 session: &Session,
1202 profile: &str,
1203 account: &str,
1204 cleared_default: bool,
1205 err: &mut impl std::io::Write,
1206) {
1207 let cache_path = crate::config::cache::path_for(&session.config_file);
1208 let mut cache = crate::config::cache::Cache::load(&cache_path);
1209 if cache.forget(profile) {
1210 cache.save(&cache_path);
1211 }
1212
1213 if cleared_default {
1214 let remaining: Vec<&str> = session
1215 .config
1216 .profiles
1217 .keys()
1218 .map(String::as_str)
1219 .filter(|name| *name != profile)
1220 .collect();
1221 let _ = writeln!(err, "default profile: {profile} → none");
1222 match remaining.as_slice() {
1223 [] => {
1224 let _ = writeln!(err, "no profiles left; `ytcli auth login` makes another");
1225 }
1226 [only] => {
1227 let _ = writeln!(err, "pick the next one: ytcli auth use {only}");
1228 }
1229 names => {
1230 let _ = writeln!(
1231 err,
1232 "pick the next one: ytcli auth use <{}>",
1233 names.join("|")
1234 );
1235 }
1236 }
1237 }
1238
1239 let still_used = session
1242 .config
1243 .profiles
1244 .iter()
1245 .any(|(name, other)| name != profile && other.account == account);
1246 if !still_used && secrets::is_stored(account) {
1247 let _ = writeln!(
1248 err,
1249 "note: account `{account}` still holds a token; ytcli auth logout --account {account} forgets it"
1250 );
1251 }
1252
1253 if let Some((path, _)) =
1256 crate::config::paths::find_project_pin(&std::env::current_dir().unwrap_or_default())
1257 .filter(|(_, pin)| pin.profile.as_deref() == Some(profile))
1258 {
1259 let _ = writeln!(
1260 err,
1261 "note: {} still names `{profile}`; update it by hand",
1262 path.display()
1263 );
1264 }
1265}
1266
1267fn rename_side_effects(session: &Session, from: &str, to: &str, err: &mut impl std::io::Write) {
1270 let cache_path = crate::config::cache::path_for(&session.config_file);
1271 let mut cache = crate::config::cache::Cache::load(&cache_path);
1272 if cache.rename(from, to) {
1273 cache.save(&cache_path);
1274 }
1275
1276 let _ = writeln!(err, "renamed profile `{from}` → `{to}`");
1277
1278 if let Some((path, _)) =
1282 crate::config::paths::find_project_pin(&std::env::current_dir().unwrap_or_default())
1283 .filter(|(_, pin)| pin.profile.as_deref() == Some(from))
1284 {
1285 let _ = writeln!(
1286 err,
1287 "note: {} still names `{from}`; update it by hand",
1288 path.display()
1289 );
1290 }
1291
1292 if session.config.default_profile.as_deref() == Some(from) {
1293 let _ = writeln!(err, "default profile: {from} → {to}");
1294 }
1295}
1296
1297fn describe_current(session: &Session, profile: &str) -> String {
1299 session
1300 .config
1301 .profiles
1302 .get(profile)
1303 .map_or_else(String::new, |current| {
1304 format!(
1305 "account={} org={} ({:?}) queue={} description={}",
1306 current.account,
1307 current.org_id,
1308 current.org_kind,
1309 current.default_queue.as_deref().unwrap_or("-"),
1310 current.description.as_deref().unwrap_or("-"),
1311 )
1312 })
1313}
1314
1315fn describe_after(session: &Session, profile: &str, edits: &store::Edits<'_>) -> String {
1318 let current = session.config.profiles.get(profile);
1319 let mut parts: Vec<String> = Vec::new();
1320
1321 if let Some(account) = edits.account {
1322 parts.push(format!("account={account}"));
1323 }
1324 if let Some(org_id) = edits.org_id {
1325 parts.push(format!("org={org_id}"));
1326 }
1327 if let Some(org_kind) = edits.org_kind {
1328 parts.push(format!("org_kind={org_kind:?}"));
1329 }
1330 match edits.default_queue {
1331 Some(Some(queue)) => parts.push(format!("queue={queue}")),
1332 Some(None) => parts.push("queue removed".to_owned()),
1333 None => {}
1334 }
1335 match edits.description {
1336 Some(Some(text)) => parts.push(format!("description=\"{text}\"")),
1337 Some(None) => parts.push("description removed".to_owned()),
1338 None => {}
1339 }
1340
1341 if parts.is_empty() {
1342 return current.map_or_else(String::new, |current| {
1345 format!("account={} org={}", current.account, current.org_id)
1346 });
1347 }
1348
1349 parts.join(" ")
1350}
1351
1352fn logout(account: &str) -> ExitCode {
1353 match secrets::forget(account) {
1354 Ok(()) => {
1355 let mut err = anstream::stderr();
1356 let _ = writeln!(err, "forgot the token for `{account}`");
1357 ExitCode::Success
1358 }
1359 Err(error) => report(&error, ExitCode::Auth),
1360 }
1361}
1362
1363fn list(session: &Session) -> ExitCode {
1367 let mut out = String::with_capacity(256);
1368
1369 let active = session
1370 .resolved
1371 .as_ref()
1372 .map(|resolved| resolved.name.clone());
1373
1374 for (name, account) in &session.config.accounts {
1375 let _ = writeln!(
1376 out,
1377 "account {name} token: {} {}",
1378 if secrets::is_stored(name) {
1379 "stored"
1380 } else {
1381 "missing"
1382 },
1383 account.description.as_deref().unwrap_or(""),
1384 );
1385 }
1386
1387 for (name, profile) in &session.config.profiles {
1388 let marks = [
1389 (session.config.default_profile.as_deref() == Some(name.as_str())).then_some("default"),
1390 (active.as_deref() == Some(name.as_str())).then_some("active"),
1391 ];
1392 let marks: Vec<&str> = marks.into_iter().flatten().collect();
1393 let suffix = if marks.is_empty() {
1394 String::new()
1395 } else {
1396 format!(" [{}]", marks.join(", "))
1397 };
1398
1399 let note = profile
1400 .description
1401 .as_deref()
1402 .map_or_else(String::new, |description| format!(" {description}"));
1403
1404 let _ = writeln!(
1405 out,
1406 "profile {name} account: {} org: {} ({:?}){suffix}{note}",
1407 profile.account, profile.org_id, profile.org_kind,
1408 );
1409 }
1410
1411 if out.is_empty() {
1412 return report(
1413 &"no accounts or profiles configured yet; see `ytcli auth login --help`",
1414 ExitCode::Auth,
1415 );
1416 }
1417
1418 emit(&out);
1419 ExitCode::Success
1420}