#![allow(dead_code)]
pub mod install;
pub mod keys;
pub mod latest;
pub mod manifest;
pub mod nudge;
pub mod path_edit;
pub mod platform;
pub mod receipt;
pub mod uninstall;
pub mod update;
pub mod verify;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use clap::{Args, Subcommand};
#[derive(Subcommand)]
pub enum SelfCommands {
Install(InstallArgs),
Update(UpdateArgs),
Uninstall(UninstallArgs),
}
#[derive(Args, Debug, Clone)]
pub struct InstallArgs {
#[arg(long, value_name = "DIR")]
pub install_dir: Option<PathBuf>,
#[arg(long)]
pub no_modify_path: bool,
#[arg(long, value_name = "SOURCE", default_value = "manual")]
pub source: String,
#[arg(long, value_name = "FILE", requires = "signature")]
pub checksums: Option<PathBuf>,
#[arg(long, value_name = "FILE", requires = "checksums")]
pub signature: Option<PathBuf>,
#[arg(long)]
pub force: bool,
}
#[derive(Args, Debug, Clone)]
pub struct UpdateArgs {
pub version: Option<String>,
#[arg(long)]
pub check: bool,
#[arg(long)]
pub dry_run: bool,
}
#[derive(Args, Debug, Clone)]
pub struct UninstallArgs {}
pub fn run(command: SelfCommands, json: bool) -> Result<()> {
match command {
SelfCommands::Install(args) => install::run(args, json),
SelfCommands::Update(args) => update::run(args, json),
SelfCommands::Uninstall(args) => uninstall::run(args, json),
}
}
pub fn maybe_nudge(command_name: &str, json: bool) {
nudge::maybe_nudge(command_name, json);
}
pub(crate) fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub(crate) fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
pub(crate) fn home_dir() -> Result<PathBuf> {
dirs::home_dir().ok_or_else(|| anyhow!("Could not find home directory"))
}
pub(crate) fn shell_basename() -> Option<String> {
let shell = std::env::var_os("SHELL")?;
Path::new(&shell)
.file_name()
.map(|name| name.to_string_lossy().to_string())
}
pub(crate) fn path_edits_disabled() -> bool {
std::env::var_os("A4_NO_MODIFY_PATH").is_some_and(|v| !v.is_empty())
|| std::env::var_os("CI").is_some()
}
pub(crate) fn absolute(path: &Path) -> Result<PathBuf> {
if path.is_absolute() {
return Ok(path.to_path_buf());
}
let cwd = std::env::current_dir().context("Failed to read the current directory")?;
Ok(cwd.join(path))
}
#[cfg(unix)]
pub(crate) fn same_file(a: &Path, b: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
match (fs::metadata(a), fs::metadata(b)) {
(Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(),
_ => false,
}
}
#[cfg(not(unix))]
pub(crate) fn same_file(a: &Path, b: &Path) -> bool {
match (fs::canonicalize(a), fs::canonicalize(b)) {
(Ok(ca), Ok(cb)) => ca == cb,
_ => false,
}
}
pub(crate) fn install_binary(src: &Path, target: &Path) -> Result<()> {
let dir = target
.parent()
.ok_or_else(|| anyhow!("{} has no parent directory", target.display()))?;
let pid = std::process::id();
let tmp = dir.join(format!("a4.tmp-{pid}"));
fs::copy(src, &tmp)
.with_context(|| format!("Failed to copy {} to {}", src.display(), tmp.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o755))
.with_context(|| format!("Failed to chmod {}", tmp.display()))?;
}
match fs::rename(&tmp, target) {
Ok(()) => Ok(()),
Err(error) => {
#[cfg(windows)]
{
if target.exists() {
let aside = dir.join(format!("a4.old-{pid}.exe"));
if fs::rename(target, &aside).is_ok() {
if fs::rename(&tmp, target).is_ok() {
if fs::remove_file(&aside).is_err() {
let _ = self_replace::self_delete_at(&aside);
}
return Ok(());
}
if let Err(restore_error) = fs::rename(&aside, target) {
return Err(anyhow!(
"Failed to replace {target} ({error}) and could not restore the previous binary ({restore_error}). \
Previous binary: {aside}. New binary: {tmp}. Move one of them to {target} by hand.",
target = target.display(),
aside = aside.display(),
tmp = tmp.display(),
));
}
}
}
}
let _ = fs::remove_file(&tmp);
Err(error).with_context(|| format!("Failed to move {} into place", target.display()))
}
}
}
pub(crate) fn removal_command(other: &Path) -> String {
let text = other.to_string_lossy();
if text.contains(".cargo") {
"cargo uninstall a4-cli".to_string()
} else if text.contains("node_modules") || text.contains("npm") || text.contains(".nvm") {
"npm uninstall -g @usearete/a4".to_string()
} else if cfg!(windows) {
format!("Remove-Item \"{text}\"")
} else {
format!("rm \"{text}\"")
}
}
pub(crate) fn print_final_lines(binary: &Path, install_dir: &Path, home: &Path) {
println!("A4_BIN={}", binary.display());
if cfg!(windows) {
println!("{}", path_edit::powershell_line(install_dir, home));
} else {
println!(
"{}",
path_edit::posix_export_line(&path_edit::shell_dir(install_dir, home))
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_binary_copies_atomically_and_is_executable() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
fs::write(&src, "#!/bin/sh\necho hi\n").unwrap();
let target = dir.path().join("bin").join(platform::binary_file_name());
fs::create_dir_all(target.parent().unwrap()).unwrap();
install_binary(&src, &target).unwrap();
assert_eq!(fs::read(&target).unwrap(), fs::read(&src).unwrap());
assert!(!dir
.path()
.join("bin")
.join(format!("a4.tmp-{}", std::process::id()))
.exists());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(&target).unwrap().permissions().mode() & 0o777,
0o755
);
}
fs::write(&src, "v2").unwrap();
install_binary(&src, &target).unwrap();
assert_eq!(fs::read(&target).unwrap(), b"v2");
assert!(same_file(&target, &target));
assert!(!same_file(&target, &src));
}
#[test]
fn removal_commands_match_origin() {
assert_eq!(
removal_command(Path::new("/Users/x/.cargo/bin/a4")),
"cargo uninstall a4-cli"
);
assert_eq!(
removal_command(Path::new("/usr/local/lib/node_modules/.bin/a4")),
"npm uninstall -g @usearete/a4"
);
assert!(removal_command(Path::new("/opt/bin/a4")).contains("/opt/bin/a4"));
}
}