use std::fmt::Write as _;
use std::io::Write as _;
use clap::{Args, Subcommand};
use crate::api::{Client, ClientConfig};
use crate::cli::{Session, emit, guidance, report, wizard};
use crate::config::{OrgKind, Profile, store};
use crate::exit::ExitCode;
use crate::oauth;
use crate::render::style::{Painter, Palette};
use crate::secrets;
#[derive(Debug, Subcommand)]
pub enum AuthCommand {
#[command(long_about = crate::cli::guidance::login_help())]
Login(LoginArgs),
#[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_REFRESH))]
Refresh {
#[arg(long, short = 'a')]
account: Option<String>,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_LOGOUT))]
Logout {
#[arg(long, short = 'a')]
account: String,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_LIST))]
List,
#[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_USE))]
Use {
profile: String,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_EDIT))]
Edit(EditArgs),
#[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_REMOVE))]
Remove {
profile: String,
},
#[command(long_about = crate::cli::help::md(crate::cli::help::AUTH_STATUS))]
Status {
#[arg(long)]
brief: bool,
#[arg(long)]
active_only: bool,
},
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Args)]
pub struct LoginArgs {
#[arg(long, short = 'a')]
pub account: Option<String>,
#[arg(long)]
pub org_id: Option<String>,
#[arg(long, value_enum)]
pub org_kind: Option<OrgKind>,
#[arg(long, short = 'p')]
pub profile: Option<String>,
#[arg(long, short = 'q')]
pub queue: Option<String>,
#[arg(long)]
pub description: Option<String>,
#[arg(long)]
pub default: bool,
#[arg(long)]
pub no_verify: bool,
#[arg(long)]
pub device: bool,
#[arg(long)]
pub read_only: bool,
}
#[derive(Debug, Args)]
pub struct EditArgs {
pub profile: String,
#[arg(long)]
pub name: Option<String>,
#[arg(long)]
pub description: Option<String>,
#[arg(long, conflicts_with = "description")]
pub clear_description: bool,
#[arg(long, short = 'a')]
pub account: Option<String>,
#[arg(long)]
pub org_id: Option<String>,
#[arg(long, value_enum)]
pub org_kind: Option<OrgKind>,
#[arg(long, short = 'q')]
pub queue: Option<String>,
#[arg(long, conflicts_with = "queue")]
pub clear_queue: bool,
}
pub async fn run(command: &AuthCommand, session: &Session) -> ExitCode {
match command {
AuthCommand::Status { brief, active_only } => status(session, *brief, *active_only).await,
AuthCommand::Login(args) => login(args, session).await,
AuthCommand::Refresh { account } => refresh(session, account.as_deref()).await,
AuthCommand::Logout { account } => logout(account),
AuthCommand::List => list(session),
AuthCommand::Use { profile } => use_profile(session, profile),
AuthCommand::Edit(args) => edit(args, session),
AuthCommand::Remove { profile } => remove(session, profile),
}
}
async fn status(session: &Session, brief: bool, active_only: bool) -> ExitCode {
let mut out = anstream::stdout();
let mut err = anstream::stderr();
let paint = session.render.painter();
if session.config.profiles.is_empty() {
let _ = writeln!(err, "no profiles configured yet.\n");
let _ = writeln!(err, "{}", guidance::full());
let _ = writeln!(
err,
"Then: ytcli auth login --account <name> --org-id <id> [--queue <QUEUE>]"
);
return ExitCode::Auth;
}
report_sources(session, paint, &mut out);
let active = session
.resolved
.as_ref()
.map(|resolved| resolved.name.clone());
let mut active_failure = None;
let mut any_success = false;
let mut last_failure = None;
let mut queues_seen: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
for (name, profile) in &session.config.profiles {
let is_active = active.as_deref() == Some(name.as_str());
if active_only && !is_active {
continue;
}
let source = if is_active {
session
.resolved
.as_ref()
.map_or_else(String::new, |resolved| {
format!(" (from {})", resolved.source)
})
} else {
String::new()
};
let marks = if is_active { " [active]" } else { "" };
let _ = writeln!(
out,
"{} {}{}{}",
paint.paint("profile", Palette::label()),
paint.paint(name, Palette::key()),
paint.paint(&source, Palette::label()),
paint.paint(marks, Palette::ok()),
);
describe_profile(profile, paint, &mut out);
let code = report_profile(
profile,
brief,
paint,
name,
&mut queues_seen,
&mut out,
&mut err,
)
.await;
if code == ExitCode::Success {
any_success = true;
} else {
last_failure = Some(code);
if is_active {
active_failure = Some(code);
}
}
}
remember_queues(session, brief, active_only, active.as_deref(), &queues_seen);
warn_about_collisions(session, paint, &queues_seen);
if secrets::overridden() && session.config.profiles.len() > 1 {
let _ = writeln!(
err,
"{} YTCLI_TOKEN is set, so every profile above was read through that one token, whatever account it names",
paint.paint("warning:", Palette::warn()),
);
}
if session.config.profiles.len() > 1 {
let _ = writeln!(
err,
"{}",
paint.paint(
"change the default with: ytcli auth use <profile>",
Palette::label()
)
);
}
active_failure
.or_else(|| (!any_success).then_some(last_failure).flatten())
.unwrap_or(ExitCode::Success)
}
fn describe_profile(
profile: &crate::config::Profile,
paint: Painter,
out: &mut impl std::io::Write,
) {
if let Some(description) = profile.description.as_deref() {
let _ = writeln!(
out,
" {} {description}",
paint.paint("note:", Palette::label()),
);
}
let _ = writeln!(
out,
" {} {} {} {} ({:?}) {} {}",
paint.paint("account:", Palette::label()),
profile.account,
paint.paint("org:", Palette::label()),
profile.org_id,
profile.org_kind,
paint.paint("queue:", Palette::label()),
profile.default_queue.as_deref().unwrap_or("-"),
);
}
fn remember_queues(
session: &Session,
brief: bool,
active_only: bool,
active: Option<&str>,
queues_seen: &std::collections::BTreeMap<String, Vec<String>>,
) {
if brief {
return;
}
let cache_path = crate::config::cache::path_for(&session.config_file);
let mut cache = crate::config::cache::Cache::load(&cache_path);
for name in session
.config
.profiles
.keys()
.filter(|name| !active_only || active == Some(name.as_str()))
{
let keys: Vec<String> = queues_seen
.iter()
.filter(|(_, profiles)| profiles.iter().any(|profile| profile == name))
.map(|(key, _)| key.clone())
.collect();
cache.record(name, &keys);
}
cache.save(&cache_path);
}
fn report_sources(session: &Session, paint: Painter, out: &mut impl std::io::Write) {
let from = match std::env::var("YTCLI_CONFIG") {
Ok(path) if session.config_file == std::path::Path::new(&path) => "from YTCLI_CONFIG",
_ if session.global.config.is_some() => "from --config",
_ => "default location",
};
let _ = writeln!(
out,
"{} {} ({})",
paint.paint("config:", Palette::label()),
session.config_file.display(),
paint.paint(from, Palette::label()),
);
let mut overriding: Vec<String> = std::env::vars()
.map(|(name, _)| name)
.filter(|name| name.starts_with("YTCLI_") && !name.is_empty())
.collect();
overriding.sort();
if !overriding.is_empty() {
let _ = writeln!(
out,
"{} {}",
paint.paint("environment:", Palette::label()),
overriding.join(", "),
);
}
}
fn warn_about_collisions(
session: &Session,
paint: Painter,
queues_seen: &std::collections::BTreeMap<String, Vec<String>>,
) {
let mut err = anstream::stderr();
let organisation = |name: &str| {
session
.config
.profiles
.get(name)
.map(|profile| profile.org_id.clone())
};
let ambiguous: Vec<(&String, &Vec<String>)> = queues_seen
.iter()
.filter(|(_, profiles)| {
profiles.len() > 1
&& profiles
.iter()
.filter_map(|name| organisation(name))
.collect::<std::collections::BTreeSet<_>>()
.len()
> 1
})
.collect();
if ambiguous.is_empty() {
return;
}
let _ = writeln!(err);
for (key, profiles) in ambiguous {
let _ = writeln!(
err,
"{} queue {key} is visible in {} — in different organisations, so a bare {key}-1 will be refused; write {}/{key}-1",
paint.paint("warning:", Palette::warn()),
profiles.join(" and "),
profiles.first().map_or("profile", String::as_str),
);
}
}
async fn report_profile(
profile: &crate::config::Profile,
brief: bool,
paint: Painter,
profile_name: &str,
queues_seen: &mut std::collections::BTreeMap<String, Vec<String>>,
out: &mut impl std::io::Write,
err: &mut impl std::io::Write,
) -> ExitCode {
let (token, origin) = match secrets::token_from(&profile.account) {
Ok(pair) => pair,
Err(error) => {
let _ = writeln!(
out,
" {} {}",
paint.paint("token:", Palette::label()),
paint.paint("missing", Palette::bad())
);
let _ = writeln!(err, " {error}");
return ExitCode::Auth;
}
};
let mut config = ClientConfig::new(token, profile.org_id.clone(), profile.org_kind);
if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
config.base_url = base;
}
if let Ok(wiki) = std::env::var("YTCLI_WIKI_URL") {
config.wiki_url = wiki;
}
let client = match Client::new(&config) {
Ok(client) => client,
Err(error) => {
let _ = writeln!(err, " {error}");
return error.exit_code();
}
};
let via = match origin {
secrets::Origin::Environment => " (from YTCLI_TOKEN)",
secrets::Origin::Keychain => " (from keychain)",
};
match client.myself().await {
Ok(user) => {
let _ = writeln!(
out,
" {} {}{via} {} {}{}",
paint.paint("token:", Palette::label()),
paint.paint("ok", Palette::ok()),
paint.paint("user:", Palette::label()),
user.login.as_deref().unwrap_or(&user.id),
user.display
.as_deref()
.map_or_else(String::new, |display| format!(" ({display})")),
);
}
Err(error) => {
let _ = writeln!(
out,
" {} {}",
paint.paint("token:", Palette::label()),
paint.paint("rejected", Palette::bad())
);
let _ = writeln!(err, " {error}");
if matches!(error, crate::api::error::ApiError::Unauthorized) {
let _ = writeln!(err, "\n{}", guidance::block(guidance::TOKEN));
}
return error.exit_code();
}
}
if brief {
return ExitCode::Success;
}
reach(&client, paint, profile_name, queues_seen, out).await;
ExitCode::Success
}
async fn reach(
client: &Client,
paint: Painter,
profile_name: &str,
queues_seen: &mut std::collections::BTreeMap<String, Vec<String>>,
out: &mut impl std::io::Write,
) {
let queues = client.queues().await.ok();
let projects = client.entities("project", None, 1, 5).await.ok();
let goals = client.entities("goal", None, 1, 1).await.ok();
let mine = client
.count("Assignee: me() AND Resolution: empty()")
.await
.ok();
let _ = writeln!(
out,
" {} {} {} {} {} {} {} {}",
paint.paint("queues:", Palette::label()),
queues
.as_ref()
.map_or_else(|| "-".to_owned(), |queues| queues.len().to_string()),
paint.paint("projects:", Palette::label()),
projects.as_ref().map_or_else(|| "-".to_owned(), count_of),
paint.paint("goals:", Palette::label()),
goals.as_ref().map_or_else(|| "-".to_owned(), count_of),
paint.paint("my open issues:", Palette::label()),
mine.map_or_else(|| "-".to_owned(), |count| count.to_string()),
);
if let Some(projects) = projects.filter(|page| !page.items.is_empty()) {
let names: Vec<String> = projects
.items
.iter()
.map(|project| {
project.short_id.map_or_else(
|| project.summary.clone(),
|id| format!("{} ({id})", project.summary),
)
})
.collect();
let more = projects
.total
.unwrap_or(names.len() as u64)
.saturating_sub(names.len() as u64);
let suffix = if more > 0 {
format!(", +{more} more")
} else {
String::new()
};
let _ = writeln!(
out,
" {} {}{suffix}",
paint.paint("projects:", Palette::label()),
names.join(", ")
);
}
if let Some(queues) = queues.filter(|queues| !queues.is_empty()) {
for queue in &queues {
queues_seen
.entry(queue.key.clone())
.or_default()
.push(profile_name.to_owned());
}
let keys: Vec<&str> = queues
.iter()
.take(8)
.map(|queue| queue.key.as_str())
.collect();
let more = queues.len().saturating_sub(keys.len());
let suffix = if more > 0 {
format!(", +{more} more")
} else {
String::new()
};
let _ = writeln!(
out,
" {} {}{suffix}",
paint.paint("queues:", Palette::label()),
keys.join(", ")
);
}
let wiki = match client.wiki_reachable().await {
Ok(()) => paint.paint("ok", Palette::ok()),
Err(crate::api::error::ApiError::WikiForbidden) => paint.paint(
"no access — the token lacks wiki:read; sign in again with `ytcli auth login`",
Palette::warn(),
),
Err(crate::api::error::ApiError::WikiNotEnabled) => paint.paint(
"not set up in this organisation — open https://wiki.yandex.ru once to start it",
Palette::warn(),
),
Err(_) => "-".to_owned(),
};
let _ = writeln!(out, " {} {wiki}", paint.paint("wiki:", Palette::label()));
}
fn count_of<T>(page: &crate::api::models::Page<T>) -> String {
page.total
.map_or_else(|| page.items.len().to_string(), |total| total.to_string())
}
async fn login(args: &LoginArgs, session: &Session) -> ExitCode {
let interactive = wizard::is_interactive();
let mut err = anstream::stderr();
if interactive && !oauth::App::is_configured() {
wizard::introduce();
}
let Identity {
account,
token,
refresh,
org_id,
org_kind: verified,
} = match identity(args, session, interactive).await {
Ok(identity) => identity,
Err(code) => return code,
};
if session.global.dry_run {
let _ = writeln!(
err,
"dry run: would store a token for `{account}` in the OS keychain"
);
} else {
if let Err(error) = secrets::store(&account, &token) {
return report(&error, ExitCode::Auth);
}
if let Err(error) = secrets::store_refresh(&account, refresh.as_deref()) {
return report(&error, ExitCode::Auth);
}
let _ = writeln!(
err,
"stored a token for `{account}` in the OS keychain{}",
if refresh.is_some() {
", with what renews it"
} else {
""
}
);
}
let Some(org_id) = org_id else {
let _ = writeln!(
err,
"no --org-id given, so no profile was written and nothing can be queried yet.\n"
);
let _ = writeln!(err, "{}", guidance::block(guidance::ORG));
let _ = writeln!(
err,
"\nThen: ytcli auth login --account {account} --org-id <id> [--queue <QUEUE>]"
);
return ExitCode::Success;
};
let org_kind = verified.unwrap_or(OrgKind::Cloud);
let shape = Shape {
account: &account,
token: &token,
org_id: &org_id,
org_kind,
interactive,
};
let (profile_name, profile, make_default) = match shape_profile(args, session, &shape).await {
Ok(shaped) => shaped,
Err(code) => return code,
};
if session.global.dry_run {
let _ = writeln!(
err,
"dry run: would write profile `{profile_name}` (account={}, org={}, {:?}{}{}) to {}",
profile.account,
profile.org_id,
profile.org_kind,
profile
.description
.as_deref()
.map_or_else(String::new, |note| format!(", {note}")),
if make_default { ", default" } else { "" },
session.config_file.display(),
);
return ExitCode::Success;
}
match store::upsert(
&session.config_file,
&account,
None,
Some((&profile_name, &profile)),
make_default,
) {
Ok(_) => {
let _ = writeln!(
err,
"wrote profile `{profile_name}` to {}{}",
session.config_file.display(),
if make_default { " (default)" } else { "" },
);
let _ = writeln!(err, "try it: ytcli auth status --active-only");
emit(&format!("{profile_name}\n"));
ExitCode::Success
}
Err(error) => report(&error, ExitCode::Failure),
}
}
struct Identity {
account: String,
token: String,
refresh: Option<String>,
org_id: Option<String>,
org_kind: Option<OrgKind>,
}
async fn identity(
args: &LoginArgs,
session: &Session,
interactive: bool,
) -> Result<Identity, ExitCode> {
let mut err = anstream::stderr();
let account = match args.account.clone() {
Some(account) => account,
None if interactive => {
let existing: Vec<String> = session.config.accounts.keys().cloned().collect();
wizard::account(&existing).map_err(|error| report(&error, error.exit_code()))?
}
None => {
return Err(report(
&"--account is required when not running in a terminal",
ExitCode::ConfirmationRequired,
));
}
};
let (token, refresh) = obtain_token(args, &account, interactive).await?;
let (org_id, org_kind) = match (&args.org_id, interactive) {
(Some(org_id), _) => (Some(org_id.clone()), args.org_kind),
(None, true) => wizard::organisation()
.map(|(id, kind)| (Some(id), kind))
.map_err(|error| report(&error, error.exit_code()))?,
(None, false) => (None, None),
};
let verified = match (&org_id, args.no_verify) {
(Some(org_id), false) => {
let (kind, who) = verify(&token, org_id, org_kind).await?;
let _ = writeln!(err, "verified as {who} in org {org_id} ({kind:?})");
Some(kind)
}
(Some(_), true) => Some(org_kind.unwrap_or(OrgKind::Cloud)),
(None, _) => None,
};
Ok(Identity {
account,
token,
refresh,
org_id,
org_kind: verified,
})
}
async fn obtain_token(
args: &LoginArgs,
account: &str,
interactive: bool,
) -> Result<(String, Option<String>), ExitCode> {
let configured = oauth::App::is_configured();
let browser = args.device
|| (interactive
&& configured
&& wizard::sign_in_in_browser().map_err(|error| report(&error, error.exit_code()))?);
if browser {
let grant = sign_in(args.read_only, interactive).await?;
if interactive && args.org_id.is_none() {
let mut err = anstream::stderr();
let _ = writeln!(err, "\n{}", guidance::block(guidance::ORG));
}
return Ok((grant.access_token, grant.refresh_token));
}
if interactive && configured {
wizard::introduce();
}
read_token(account, interactive).map(|token| (token, None))
}
async fn sign_in(read_only: bool, interactive: bool) -> Result<oauth::Grant, ExitCode> {
let fail = |error: oauth::OAuthError| report(&error, error.exit_code());
let mut err = anstream::stderr();
let app = oauth::App::from_environment().map_err(fail)?;
let code = app
.request_code(read_only.then_some(oauth::READ_ONLY_SCOPE))
.await
.map_err(fail)?;
let paint = Painter::for_stream(std::io::IsTerminal::is_terminal(&std::io::stderr()));
let expires = code.expires_in.map_or_else(String::new, |seconds| {
format!(" (expires in {} min)", seconds.div_ceil(60))
});
let _ = writeln!(
err,
"\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",
paint.paint("Sign in with Yandex", Palette::heading()),
paint.paint(&code.user_code, Palette::key()),
paint.link(&code.verification_url),
paint.paint(&expires, Palette::label()),
paint.paint(
"Only confirm a code you started here yourself.",
Palette::label()
),
);
let early = if interactive {
wizard::press_enter("Press Enter to open the page in your browser… ")
.map_err(|error| report(&error, error.exit_code()))?;
let early = app.try_grant(&code).await.map_err(fail)?;
if early.is_none() {
open_browser(&code.verification_url);
}
early
} else {
None
};
let grant = if let Some(grant) = early {
grant
} else {
let _ = writeln!(err, "waiting for the code to be confirmed…");
app.await_grant(&code).await.map_err(fail)?
};
let _ = writeln!(
err,
"signed in{}",
if grant.refresh_token.is_some() {
"; renew later with `ytcli auth refresh`"
} else {
""
}
);
Ok(grant)
}
fn open_browser(url: &str) {
let plain = ["https://ya.ru/", "https://oauth.yandex."]
.iter()
.any(|prefix| url.starts_with(prefix))
&& url
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b":/.-_".contains(&byte));
if !plain {
return;
}
let mut command = if cfg!(target_os = "macos") {
std::process::Command::new("open")
} else if cfg!(windows) {
let mut command = std::process::Command::new("cmd");
command.args(["/C", "start", ""]);
command
} else {
std::process::Command::new("xdg-open")
};
let _ = command
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
async fn refresh(session: &Session, account: Option<&str>) -> ExitCode {
let mut err = anstream::stderr();
let Some(account) = account.map(ToOwned::to_owned).or_else(|| {
session
.resolved
.as_ref()
.map(|resolved| resolved.profile.account.clone())
}) else {
return report(
&"no --account given, and no active profile to take one from",
ExitCode::Auth,
);
};
let using: Vec<String> = session
.config
.profiles
.iter()
.filter(|(_, profile)| profile.account == account)
.map(|(name, profile)| format!("{name} (org {})", profile.org_id))
.collect();
let _ = writeln!(
err,
"renewing the token of account `{account}`, used by: {}",
if using.is_empty() {
"no profile".to_owned()
} else {
using.join(", ")
}
);
let refresh_token = match secrets::refresh_token(&account) {
Ok(Some(token)) => token,
Ok(None) => {
return report(
&format!(
"`{account}` has nothing to renew it with: its token was pasted, not signed in for. \
Run `ytcli auth login --account {account}`"
),
ExitCode::Auth,
);
}
Err(error) => return report(&error, ExitCode::Auth),
};
if session.global.dry_run {
let _ = writeln!(
err,
"dry run: would exchange the refresh token of `{account}` for a new token"
);
return ExitCode::Success;
}
let grant = match oauth::App::from_environment() {
Ok(app) => app.refresh(&refresh_token).await,
Err(error) => Err(error),
};
let grant = match grant {
Ok(grant) => grant,
Err(error) => return report(&error, error.exit_code()),
};
let unchanged = secrets::token(&account).is_ok_and(|current| current == grant.access_token);
if let Err(error) = secrets::store(&account, &grant.access_token) {
return report(&error, ExitCode::Auth);
}
let renews = grant.refresh_token.as_deref().unwrap_or(&refresh_token);
if let Err(error) = secrets::store_refresh(&account, Some(renews)) {
return report(&error, ExitCode::Auth);
}
if unchanged {
let _ = writeln!(
err,
"Yandex kept the same token for `{account}`: it has long enough left to run"
);
} else {
let _ = writeln!(err, "stored a renewed token for `{account}`");
}
ExitCode::Success
}
struct Shape<'a> {
account: &'a str,
token: &'a str,
org_id: &'a str,
org_kind: OrgKind,
interactive: bool,
}
async fn shape_profile(
args: &LoginArgs,
session: &Session,
shape: &Shape<'_>,
) -> Result<(String, Profile, bool), ExitCode> {
let profile_name = match args.profile.clone() {
Some(name) => name,
None if shape.interactive => {
wizard::profile(shape.account).map_err(|error| report(&error, error.exit_code()))?
}
None => shape.account.to_owned(),
};
let queue = match args.queue.clone() {
Some(queue) => Some(queue),
None if shape.interactive => {
let available = queue_keys(shape.token, shape.org_id, shape.org_kind).await;
if !session.global.dry_run {
let cache_path = crate::config::cache::path_for(&session.config_file);
let mut cache = crate::config::cache::Cache::load(&cache_path);
cache.record(&profile_name, &available);
cache.save(&cache_path);
}
wizard::queue(&available).map_err(|error| report(&error, error.exit_code()))?
}
None => None,
};
let existing = session
.config
.profiles
.get(&profile_name)
.and_then(|profile| profile.description.clone());
let description = match (args.description.clone(), shape.interactive) {
(Some(text), _) => Some(text),
(None, true) => wizard::description(existing.as_deref())
.map_err(|error| report(&error, error.exit_code()))?
.or(existing),
(None, false) => existing,
};
let current_default = session.config.default_profile.as_deref();
let make_default = if args.default || current_default.is_none() {
true
} else if shape.interactive {
wizard::make_default(&profile_name, current_default)
.map_err(|error| report(&error, error.exit_code()))?
} else {
false
};
Ok((
profile_name,
Profile {
account: shape.account.to_owned(),
org_id: shape.org_id.to_owned(),
org_kind: shape.org_kind,
description,
default_queue: queue,
display: crate::config::Display::default(),
},
make_default,
))
}
async fn queue_keys(token: &str, org_id: &str, kind: OrgKind) -> Vec<String> {
let mut config = ClientConfig::new(token.to_owned(), org_id.to_owned(), kind);
if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
config.base_url = base;
}
let Ok(client) = Client::new(&config) else {
return Vec::new();
};
client.queues().await.map_or_else(
|_| Vec::new(),
|queues| queues.into_iter().map(|queue| queue.key).collect(),
)
}
fn read_token(account: &str, interactive: bool) -> Result<String, ExitCode> {
if interactive {
return wizard::token(account).map_err(|error| report(&error, error.exit_code()));
}
let mut piped = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut piped)
.map_err(|error| report(&error, ExitCode::Failure))?;
let token = piped.trim().to_owned();
if token.is_empty() {
return Err(report(&"no token given", ExitCode::Auth));
}
Ok(token)
}
async fn verify(
token: &str,
org_id: &str,
kind: Option<OrgKind>,
) -> Result<(OrgKind, String), ExitCode> {
let candidates: Vec<OrgKind> = match kind {
Some(kind) => vec![kind],
None => vec![OrgKind::Cloud, OrgKind::Yandex360],
};
let mut last: Option<crate::api::error::ApiError> = None;
for candidate in candidates {
let mut config = ClientConfig::new(token.to_owned(), org_id.to_owned(), candidate);
if let Ok(base) = std::env::var("YTCLI_BASE_URL") {
config.base_url = base;
}
let client = match Client::new(&config) {
Ok(client) => client,
Err(error) => {
let code = error.exit_code();
return Err(report(&error, code));
}
};
match client.myself().await {
Ok(user) => {
let who = user.login.or(user.display).unwrap_or(user.id);
return Ok((candidate, who));
}
Err(error @ crate::api::error::ApiError::Unauthorized) => {
let code = error.exit_code();
let reported = report(&error, code);
let mut err = anstream::stderr();
let _ = writeln!(err, "\n{}", guidance::block(guidance::TOKEN));
return Err(reported);
}
Err(error) => last = Some(error),
}
}
let error = last.unwrap_or(crate::api::error::ApiError::Forbidden);
let code = error.exit_code();
let reported = report(
&format!("{error} — checked both organisation header forms"),
code,
);
let mut err = anstream::stderr();
let _ = writeln!(err, "\n{}", guidance::block(guidance::ORG));
Err(reported)
}
fn unknown<'a>(what: &str, name: &str, configured: impl Iterator<Item = &'a String>) -> ExitCode {
let known: Vec<&str> = configured.map(String::as_str).collect();
report(
&format!(
"no {what} called `{name}`; configured: {}",
if known.is_empty() {
"none — run `ytcli auth login`".to_owned()
} else {
known.join(", ")
}
),
ExitCode::NotFound,
)
}
fn use_profile(session: &Session, profile: &str) -> ExitCode {
let mut err = anstream::stderr();
if !session.config.profiles.contains_key(profile) {
return unknown("profile", profile, session.config.profiles.keys());
}
let previous = session.config.default_profile.clone();
if previous.as_deref() == Some(profile) {
let _ = writeln!(err, "`{profile}` is already the default profile");
return ExitCode::Success;
}
if session.global.dry_run {
let _ = writeln!(
err,
"dry run: would make `{profile}` the default profile in {}",
session.config_file.display()
);
return ExitCode::Success;
}
match store::set_default(&session.config_file, profile) {
Ok(_) => {
let _ = writeln!(
err,
"default profile: {} → {profile}",
previous.as_deref().unwrap_or("none"),
);
ExitCode::Success
}
Err(error) => report(&error, ExitCode::Failure),
}
}
fn edit(args: &EditArgs, session: &Session) -> ExitCode {
let mut err = anstream::stderr();
if !session.config.profiles.contains_key(&args.profile) {
return unknown("profile", &args.profile, session.config.profiles.keys());
}
if let Some(account) = args
.account
.as_deref()
.filter(|account| !session.config.accounts.contains_key(*account))
{
return unknown("account", account, session.config.accounts.keys());
}
let description = if args.clear_description {
Some(None)
} else {
args.description
.as_deref()
.map(str::trim)
.map(|text| (!text.is_empty()).then_some(text))
};
let edits = store::Edits {
name: args.name.as_deref(),
account: args.account.as_deref(),
org_id: args.org_id.as_deref(),
org_kind: args.org_kind,
description,
default_queue: if args.clear_queue {
Some(None)
} else {
args.queue.as_deref().map(Some)
},
};
if edits.is_empty() {
return report(
&format!(
"nothing to change; pass --name, --description, --account, --org-id, --org-kind or --queue (see `ytcli auth edit --help`)\ncurrently: {}",
describe_current(session, &args.profile)
),
ExitCode::ConfirmationRequired,
);
}
if session.global.dry_run {
let _ = writeln!(
err,
"dry run: would change profile `{}` in {}",
args.profile,
session.config_file.display()
);
return ExitCode::Success;
}
match store::edit(&session.config_file, &args.profile, &edits) {
Ok(_) => {
let name = args.name.as_deref().unwrap_or(&args.profile);
if let Some(new_name) = args.name.as_deref().filter(|name| *name != args.profile) {
rename_side_effects(session, &args.profile, new_name, &mut err);
}
let _ = writeln!(
err,
"profile `{name}`: {}",
describe_after(session, &args.profile, &edits)
);
if args.org_id.is_some() || args.org_kind.is_some() || args.account.is_some() {
let _ = writeln!(
err,
"check it: ytcli auth status --profile {name} --active-only"
);
}
emit(&format!("{name}\n"));
ExitCode::Success
}
Err(error) => {
let code = match error {
store::EditError::Unknown(_) => ExitCode::NotFound,
store::EditError::NameTaken(_) | store::EditError::Store(_) => ExitCode::Failure,
};
report(&error, code)
}
}
}
fn remove(session: &Session, profile: &str) -> ExitCode {
let mut err = anstream::stderr();
let Some(current) = session.config.profiles.get(profile) else {
return unknown("profile", profile, session.config.profiles.keys());
};
let about = format!(
"account={} org={} ({:?})",
current.account, current.org_id, current.org_kind
);
if session.global.dry_run {
let _ = writeln!(
err,
"dry run: would remove profile `{profile}` ({about}) from {}",
session.config_file.display()
);
return ExitCode::Success;
}
if !session.global.yes {
let _ = writeln!(
err,
"refusing to remove profile `{profile}` ({about}) without --yes: \
its display settings and pinned fields live only in {}",
session.config_file.display()
);
return ExitCode::ConfirmationRequired;
}
let account = current.account.clone();
match store::remove(&session.config_file, profile) {
Ok(removed) => {
let _ = writeln!(err, "removed profile `{profile}` ({about})");
removal_side_effects(
session,
profile,
&account,
removed.cleared_default,
&mut err,
);
emit(&format!("{profile}\n"));
ExitCode::Success
}
Err(error) => {
let code = match error {
store::EditError::Unknown(_) => ExitCode::NotFound,
store::EditError::NameTaken(_) | store::EditError::Store(_) => ExitCode::Failure,
};
report(&error, code)
}
}
}
fn removal_side_effects(
session: &Session,
profile: &str,
account: &str,
cleared_default: bool,
err: &mut impl std::io::Write,
) {
let cache_path = crate::config::cache::path_for(&session.config_file);
let mut cache = crate::config::cache::Cache::load(&cache_path);
if cache.forget(profile) {
cache.save(&cache_path);
}
if cleared_default {
let remaining: Vec<&str> = session
.config
.profiles
.keys()
.map(String::as_str)
.filter(|name| *name != profile)
.collect();
let _ = writeln!(err, "default profile: {profile} → none");
match remaining.as_slice() {
[] => {
let _ = writeln!(err, "no profiles left; `ytcli auth login` makes another");
}
[only] => {
let _ = writeln!(err, "pick the next one: ytcli auth use {only}");
}
names => {
let _ = writeln!(
err,
"pick the next one: ytcli auth use <{}>",
names.join("|")
);
}
}
}
let still_used = session
.config
.profiles
.iter()
.any(|(name, other)| name != profile && other.account == account);
if !still_used && secrets::is_stored(account) {
let _ = writeln!(
err,
"note: account `{account}` still holds a token; ytcli auth logout --account {account} forgets it"
);
}
if let Some((path, _)) =
crate::config::paths::find_project_pin(&std::env::current_dir().unwrap_or_default())
.filter(|(_, pin)| pin.profile.as_deref() == Some(profile))
{
let _ = writeln!(
err,
"note: {} still names `{profile}`; update it by hand",
path.display()
);
}
}
fn rename_side_effects(session: &Session, from: &str, to: &str, err: &mut impl std::io::Write) {
let cache_path = crate::config::cache::path_for(&session.config_file);
let mut cache = crate::config::cache::Cache::load(&cache_path);
if cache.rename(from, to) {
cache.save(&cache_path);
}
let _ = writeln!(err, "renamed profile `{from}` → `{to}`");
if let Some((path, _)) =
crate::config::paths::find_project_pin(&std::env::current_dir().unwrap_or_default())
.filter(|(_, pin)| pin.profile.as_deref() == Some(from))
{
let _ = writeln!(
err,
"note: {} still names `{from}`; update it by hand",
path.display()
);
}
if session.config.default_profile.as_deref() == Some(from) {
let _ = writeln!(err, "default profile: {from} → {to}");
}
}
fn describe_current(session: &Session, profile: &str) -> String {
session
.config
.profiles
.get(profile)
.map_or_else(String::new, |current| {
format!(
"account={} org={} ({:?}) queue={} description={}",
current.account,
current.org_id,
current.org_kind,
current.default_queue.as_deref().unwrap_or("-"),
current.description.as_deref().unwrap_or("-"),
)
})
}
fn describe_after(session: &Session, profile: &str, edits: &store::Edits<'_>) -> String {
let current = session.config.profiles.get(profile);
let mut parts: Vec<String> = Vec::new();
if let Some(account) = edits.account {
parts.push(format!("account={account}"));
}
if let Some(org_id) = edits.org_id {
parts.push(format!("org={org_id}"));
}
if let Some(org_kind) = edits.org_kind {
parts.push(format!("org_kind={org_kind:?}"));
}
match edits.default_queue {
Some(Some(queue)) => parts.push(format!("queue={queue}")),
Some(None) => parts.push("queue removed".to_owned()),
None => {}
}
match edits.description {
Some(Some(text)) => parts.push(format!("description=\"{text}\"")),
Some(None) => parts.push("description removed".to_owned()),
None => {}
}
if parts.is_empty() {
return current.map_or_else(String::new, |current| {
format!("account={} org={}", current.account, current.org_id)
});
}
parts.join(" ")
}
fn logout(account: &str) -> ExitCode {
match secrets::forget(account) {
Ok(()) => {
let mut err = anstream::stderr();
let _ = writeln!(err, "forgot the token for `{account}`");
ExitCode::Success
}
Err(error) => report(&error, ExitCode::Auth),
}
}
fn list(session: &Session) -> ExitCode {
let mut out = String::with_capacity(256);
let active = session
.resolved
.as_ref()
.map(|resolved| resolved.name.clone());
for (name, account) in &session.config.accounts {
let _ = writeln!(
out,
"account {name} token: {} {}",
if secrets::is_stored(name) {
"stored"
} else {
"missing"
},
account.description.as_deref().unwrap_or(""),
);
}
for (name, profile) in &session.config.profiles {
let marks = [
(session.config.default_profile.as_deref() == Some(name.as_str())).then_some("default"),
(active.as_deref() == Some(name.as_str())).then_some("active"),
];
let marks: Vec<&str> = marks.into_iter().flatten().collect();
let suffix = if marks.is_empty() {
String::new()
} else {
format!(" [{}]", marks.join(", "))
};
let note = profile
.description
.as_deref()
.map_or_else(String::new, |description| format!(" {description}"));
let _ = writeln!(
out,
"profile {name} account: {} org: {} ({:?}){suffix}{note}",
profile.account, profile.org_id, profile.org_kind,
);
}
if out.is_empty() {
return report(
&"no accounts or profiles configured yet; see `ytcli auth login --help`",
ExitCode::Auth,
);
}
emit(&out);
ExitCode::Success
}