use super::Invocation;
use crate::bulk::StateDir;
use crate::clients::SINGLE_REQUEST_BUDGET;
use crate::error::{CliError, Failure, InterruptContext};
use crate::files::{self, Published, shown};
use crate::modelsec::fetch::{self, Managed, Source, UV_VERSION};
use crate::modelsec::pypi;
use crate::modelsec::uv::{self, Ended};
use crate::modelsec::{self, views};
use crate::output::{Format, terminal_safe};
use crate::render::{pretty, table};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InstallPlan {
pub extras: String,
pub dir: OsString,
pub dry_run: bool,
pub fetch_uv: bool,
pub hook: Option<(String, String)>,
}
fn within(context: &InterruptContext) -> impl Fn(CliError) -> CliError + '_ {
move |error| match error {
CliError::Interrupted(_) => CliError::Interrupted(context.clone()),
other => other,
}
}
fn context(text: String) -> InterruptContext {
InterruptContext::Context(text)
}
fn check_url_file(path: &Path) -> Result<(), CliError> {
files::refuse_existing(path, "--url-file")?;
let dir = match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
_ => PathBuf::from("."),
};
if std::fs::metadata(&dir).is_ok_and(|metadata| metadata.is_dir()) {
Ok(())
} else {
Err(CliError::operational(format!(
"Cannot write {}; check that its directory exists and is writable",
shown(path.as_os_str())
)))
}
}
async fn pypi_url(invocation: &mut Invocation) -> Result<(String, String), CliError> {
let options = modelsec::options(SINGLE_REQUEST_BUDGET)?;
let connected = invocation.connect_model_security(None).await?;
let auth = connected
.client
.get_pypi_auth(Some(&options))
.await
.map_err(|error| invocation.management_error(&error, SINGLE_REQUEST_BUDGET))?;
pypi::register(&auth.url, &invocation.secrets);
Ok((auth.url, auth.expires_at))
}
pub async fn pypi_auth(
invocation: &mut Invocation,
url_file: Option<&OsString>,
format: Option<Format>,
) -> Result<(), CliError> {
let url_file = url_file.map(PathBuf::from);
if let Some(path) = &url_file {
check_url_file(path)?;
}
let (url, expires) = pypi_url(invocation).await?;
let format = format.unwrap_or(Format::Pretty);
if let Some(path) = &url_file {
if pypi::check(&url).is_none() {
return Err(CliError::operational(pypi::UNUSABLE_FOR_FILE));
}
if invocation.output.pending_signal().is_some() {
return Err(CliError::Interrupted(InterruptContext::NoDataWritten));
}
let published = files::publish_new(path, format!("{url}\n").as_bytes(), "--url-file")?;
let typed = terminal_safe(&shown(path.as_os_str())).into_owned();
invocation.interrupt = Some(context(format!("{typed} was written")));
if published == Published::DirectoryNotSynced {
invocation
.output
.warning(&crate::store::atomic::dir_sync_warning(path));
}
invocation.output.success(&format!(
"Wrote the PyPI index URL, credential included, to {typed} (mode 0600)."
));
}
let form = pypi::withheld_form(&url, &invocation.secrets);
let expires = if invocation.secrets.carried_by(&expires) {
crate::error::WITHHELD.to_owned()
} else {
terminal_safe(&expires).into_owned()
};
let out = &mut invocation.output;
match format {
Format::Json => out.json(&serde_json::json!({"url": form, "expiresAt": expires}))?,
Format::Table => out.data_line(&table::key_value_table(vec![
("url".to_owned(), form),
("expiresAt".to_owned(), expires),
])),
Format::Pretty => {
views::banner(out);
pretty::section(out, "PyPI Authentication");
pretty::key_value(out, &[("URL", form), ("Expires", expires)]);
}
}
if url_file.is_none() {
out.status(
"The index credential is withheld; pass --url-file <path> to save the full URL in a new private file.",
);
}
Ok(())
}
struct Resolved {
uv: PathBuf,
download_note: Option<String>,
}
fn tools_dir() -> Result<PathBuf, CliError> {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.or_else(std::env::home_dir);
fetch::tools_dir_path(
std::env::var_os("XDG_DATA_HOME").as_deref(),
home.as_deref(),
)
}
async fn managed_uv(invocation: &mut Invocation, plan: &InstallPlan) -> Result<Resolved, CliError> {
let source = Source::for_host(plan.hook.as_ref())?;
let tools_path = tools_dir()?;
let exists = !matches!(
std::fs::symlink_metadata(&tools_path),
Err(error) if error.kind() == std::io::ErrorKind::NotFound
);
let tools = if exists {
Some(StateDir::open(&tools_path)?)
} else if plan.dry_run {
None
} else {
Some(StateDir::ensure(&tools_path)?)
};
let managed = match &tools {
Some(tools) => fetch::verify_managed(tools, &source)?,
None => Managed::Absent,
};
let nothing = uv::nothing_installed();
if let Managed::Verified(path) = managed {
let version = uv::check_version(
&path,
Some(fetch::UV_VERSION_PARTS),
&invocation.output,
¬hing,
)?;
invocation
.output
.status(&format!("Using uv {version} ({})", shown(path.as_os_str())));
return Ok(Resolved {
uv: path,
download_note: None,
});
}
let base = tools
.as_ref()
.map_or_else(|| tools_path.clone(), |tools| tools.path().to_path_buf());
let managed_dir = base.join(fetch::managed_name(source.target));
if plan.dry_run {
return Ok(Resolved {
uv: managed_dir.join("uv"),
download_note: Some(
terminal_safe(&format!(
" # would download uv {UV_VERSION} ({}, sha256 {}) into {}",
fetch::archive_name(source.target),
source.sha256,
shown(base.as_os_str())
))
.into_owned(),
),
});
}
let Some(tools) = tools else {
return Err(CliError::Interrupted(nothing));
};
let not_installed = fetch::not_installed();
invocation.interrupt = Some(not_installed.clone());
invocation.output.status(&format!(
"→ Downloading uv {UV_VERSION} ({})",
source.target
));
let session = invocation.session.clone();
let body = tokio::select! {
body = fetch::download(&source) => body?,
() = session.cancelled() => return Err(CliError::Interrupted(not_installed)),
};
fetch::verify_archive(&body, &source.sha256)?;
let tar = fetch::gunzip(&body)?;
let binary = fetch::extract_uv(&tar, source.target)?;
let signals = invocation.output.signals();
let interrupted = || {
signals
.as_ref()
.is_some_and(|signals| signals.code().is_some())
};
let path = fetch::install(&tools, &source, &binary, &interrupted)?;
let shown_dir = terminal_safe(&shown(managed_dir.as_os_str())).into_owned();
let installed = context(format!(
"uv {UV_VERSION} was installed in {shown_dir}; nothing else was installed"
));
invocation.interrupt = Some(installed.clone());
let version = uv::check_version(
&path,
Some(fetch::UV_VERSION_PARTS),
&invocation.output,
&installed,
)?;
invocation
.output
.success(&format!("Installed uv {UV_VERSION} in {shown_dir}"));
invocation
.output
.status(&format!("Using uv {version} ({})", shown(path.as_os_str())));
Ok(Resolved {
uv: path,
download_note: None,
})
}
fn path_uv(invocation: &mut Invocation) -> Result<Resolved, CliError> {
let Some(path) = uv::find_on_path(std::env::var_os("PATH").as_deref()) else {
let tools = tools_dir().unwrap_or_else(|_| PathBuf::from("the airs-cli tools directory"));
return Err(uv::not_found(&tools));
};
let version = uv::check_version(&path, None, &invocation.output, &uv::nothing_installed())?;
invocation
.output
.status(&format!("Using uv {version} ({})", shown(path.as_os_str())));
Ok(Resolved {
uv: path,
download_note: None,
})
}
pub async fn install(invocation: &mut Invocation, plan: &InstallPlan) -> Result<(), CliError> {
let typed = terminal_safe(&shown(&plan.dir)).into_owned();
let dir = uv::preflight_dir(&plan.dir, invocation.env.cwd.as_deref())?;
let resolved = if plan.fetch_uv {
managed_uv(invocation, plan).await?
} else {
path_uv(invocation)?
};
let nothing = invocation
.interrupt
.clone()
.unwrap_or_else(uv::nothing_installed);
let (url, _) = pypi_url(invocation).await.map_err(within(¬hing))?;
let index =
pypi::check(&url).ok_or_else(|| CliError::operational(pypi::UNUSABLE_FOR_INSTALL))?;
let quiet = invocation.output.quiet();
let steps = uv::steps(&dir, &index.index, &plan.extras, quiet);
if plan.dry_run {
let out = &mut invocation.output;
views::banner(out);
pretty::section(out, "Commands that would be executed");
if let Some(note) = &resolved.download_note {
out.data_line(note);
}
for step in &steps {
out.data_line(&uv::plan_line(&resolved.uv, step));
}
return Ok(());
}
if invocation.output.pending_signal().is_some() {
return Err(CliError::Interrupted(nothing));
}
match std::fs::DirBuilder::new().create(&dir) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(CliError::operational(format!(
"{typed} already exists; choose another --dir"
)));
}
Err(_) => {
return Err(CliError::operational(format!(
"Cannot create {typed}; check the permissions of its parent directory"
)));
}
}
for step in &steps {
let name = step.label.trim_start_matches("uv ");
let stopped = context(format!("uv {name} was stopped; {typed} may be incomplete"));
invocation.interrupt = Some(stopped.clone());
invocation.output.status(&format!("→ {}", step.label));
let ended = uv::run_step(
&mut invocation.output,
&resolved.uv,
step,
&index,
&invocation.secrets,
)?;
let failure = match ended {
Ended::Exited(0) => continue,
Ended::Interrupted => return Err(CliError::Interrupted(stopped)),
Ended::Exited(code) => format!("{} failed with exit code {code}", step.label),
Ended::Signalled(signal) => {
format!("{} was terminated by signal {signal}", step.label)
}
};
let details = if step.credential {
vec![
format!(
"{typed} was created, but model-security-client was not added. Remove {typed} and re-run, or add it with uv yourself."
),
uv::NO_BUILD_HINT.to_owned(),
]
} else {
Vec::new()
};
return Err(CliError::Operational(Failure {
message: failure,
status: None,
details,
}));
}
invocation.interrupt = Some(context(format!(
"model-security-client was installed in {typed}"
)));
uv::persistence_check(&dir, &index)?;
let out = &mut invocation.output;
out.success("model-security-client installed successfully.");
out.status(&format!(
"Activate: cd {}",
terminal_safe(&uv::shell_word(&dir.to_string_lossy()))
));
out.status(
"Later uv commands that use the AIRS index need the credential again: set UV_INDEX_AIRS_MODEL_SECURITY_USERNAME and UV_INDEX_AIRS_MODEL_SECURITY_PASSWORD from the URL that 'airs-cli-rs model-security pypi-auth --url-file <file>' saves.",
);
Ok(())
}