diff options
| author | jimvdl <jimvdlind@gmail.com> | 2023-08-26 20:44:58 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-08-26 20:44:58 +0200 |
| commit | 7bdf1f57b09ea605045254013a8200373451baf0 (patch) | |
| tree | 0d7264dc2ca3aaf41b31e5226d8562cb4f68121e /crates/typst-cli | |
| parent | 879182059020e4669efae2296e98cdc8360a3bc2 (diff) | |
Let the CLI `typst update` itself without a package manager (#1887)
Diffstat (limited to 'crates/typst-cli')
| -rw-r--r-- | crates/typst-cli/Cargo.toml | 11 | ||||
| -rw-r--r-- | crates/typst-cli/build.rs | 2 | ||||
| -rw-r--r-- | crates/typst-cli/src/args.rs | 21 | ||||
| -rw-r--r-- | crates/typst-cli/src/main.rs | 17 | ||||
| -rw-r--r-- | crates/typst-cli/src/update.rs | 246 |
5 files changed, 296 insertions, 1 deletions
diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml index c5b38be6..8396e97b 100644 --- a/crates/typst-cli/Cargo.toml +++ b/crates/typst-cli/Cargo.toml @@ -35,20 +35,26 @@ once_cell = "1" open = "4.0.2" pathdiff = "0.1" same-file = "1" +# https://github.com/mitsuhiko/self-replace/pull/16 +self-replace = { git = "https://github.com/typst/self-replace", optional = true } +semver = "1" serde = "1.0.184" serde_json = "1" serde_yaml = "0.9" siphasher = "0.3" tar = "0.4" -tempfile = "3.5.0" +tempfile = "3.7.0" tracing = "0.1.37" tracing-error = "0.2" tracing-flame = "0.2.0" tracing-subscriber = "0.3.17" ureq = "2" walkdir = "2" +xz2 = { version = "0.1", optional = true } +zip = { version = "0.6", optional = true } [build-dependencies] +semver = "1" clap = { version = "4.2.4", features = ["derive", "string"] } clap_complete = "4.2.1" clap_mangen = "0.2.10" @@ -61,3 +67,6 @@ default = ["embed-fonts"] # - For math: New Computer Modern Math # - For code: Deja Vu Sans Mono embed-fonts = [] + +# Permits the CLI to update itself without a package manager +self-update = ["dep:self-replace", "dep:xz2", "dep:zip", "ureq/json"] diff --git a/crates/typst-cli/build.rs b/crates/typst-cli/build.rs index 86325e1d..bd6a563d 100644 --- a/crates/typst-cli/build.rs +++ b/crates/typst-cli/build.rs @@ -12,6 +12,8 @@ use clap_mangen::Man; mod args; fn main() { + // https://stackoverflow.com/a/51311222/11494565 + println!("cargo:rustc-env=TARGET={}", env::var("TARGET").unwrap()); println!("cargo:rerun-if-env-changed=TYPST_VERSION"); println!("cargo:rerun-if-env-changed=GEN_ARTIFACTS"); diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index 2696a031..c741ecfc 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -1,6 +1,8 @@ use std::fmt::{self, Display, Formatter}; use std::path::PathBuf; +use semver::Version; + use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum}; /// The Typst compiler. @@ -34,6 +36,10 @@ pub enum Command { /// Lists all discovered fonts in system and custom font paths Fonts(FontsCommand), + + /// Self update the Typst CLI + #[cfg_attr(not(feature = "self-update"), doc = " (disabled)")] + Update(UpdateCommand), } /// Compiles an input file into a supported output format @@ -154,6 +160,21 @@ impl Display for DiagnosticFormat { } } +#[derive(Debug, Clone, Parser)] +pub struct UpdateCommand { + /// Which version to update to (defaults to latest) + pub version: Option<Version>, + + /// Forces a downgrade to an older version (required for downgrading) + #[clap(long, default_value_t = false)] + pub force: bool, + + /// Reverts to the version from before the last update (only possible if + /// `typst update` has previously ran) + #[clap(long, default_value_t = false, exclusive = true)] + pub revert: bool, +} + /// Which format to use for the generated output file. #[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, ValueEnum)] pub enum OutputFormat { diff --git a/crates/typst-cli/src/main.rs b/crates/typst-cli/src/main.rs index 62f14566..b88a0ce4 100644 --- a/crates/typst-cli/src/main.rs +++ b/crates/typst-cli/src/main.rs @@ -4,6 +4,8 @@ mod fonts; mod package; mod query; mod tracing; +#[cfg(feature = "self-update")] +mod update; mod watch; mod world; @@ -39,6 +41,7 @@ fn main() -> ExitCode { Command::Watch(command) => crate::watch::watch(command), Command::Query(command) => crate::query::query(command), Command::Fonts(command) => crate::fonts::fonts(command), + Command::Update(command) => crate::update::update(command), }; if let Err(msg) = res { @@ -79,3 +82,17 @@ fn color_stream() -> termcolor::StandardStream { fn typst_version() -> &'static str { env!("TYPST_VERSION") } + +#[cfg(not(feature = "self-update"))] +mod update { + use crate::args::UpdateCommand; + use typst::diag::{bail, StrResult}; + + pub fn update(_: UpdateCommand) -> StrResult<()> { + bail!( + "self-updating is not enabled for this executable, \ + please update with the package manager or mechanism \ + used for initial installation" + ) + } +} diff --git a/crates/typst-cli/src/update.rs b/crates/typst-cli/src/update.rs new file mode 100644 index 00000000..617da4d1 --- /dev/null +++ b/crates/typst-cli/src/update.rs @@ -0,0 +1,246 @@ +use std::env; +use std::fs; +use std::io::{Cursor, Read, Write}; +use std::path::PathBuf; + +use semver::Version; +use serde::Deserialize; +use tempfile::NamedTempFile; +use typst::{diag::bail, diag::StrResult, eval::eco_format}; +use xz2::bufread::XzDecoder; +use zip::ZipArchive; + +use crate::args::UpdateCommand; + +const TYPST_GITHUB_ORG: &str = "typst"; +const TYPST_REPO: &str = "typst"; + +/// Self update the Typst CLI binary. +/// +/// Fetches a target release or the latest release (if no version was specified) +/// from GitHub, unpacks it and self replaces the current binary with the +/// pre-compiled asset from the downloaded release. +pub fn update(command: UpdateCommand) -> StrResult<()> { + if let Some(ref version) = command.version { + let current_tag = env!("CARGO_PKG_VERSION").parse().unwrap(); + + if version < &Version::new(0, 8, 0) { + eprintln!( + "Note: Versions older than 0.8.0 will not have \ + the update command available." + ); + } + + if !command.force && version < ¤t_tag { + bail!( + "downgrading requires the --force flag: \ + `typst update <VERSION> --force`" + ); + } + } + + let backup_path = backup_path()?; + if command.revert { + if !backup_path.exists() { + bail!( + "unable to revert, no backup found (searched at {})", + backup_path.display() + ); + } + + return self_replace::self_replace(&backup_path) + .and_then(|_| fs::remove_file(&backup_path)) + .map_err(|err| eco_format!("failed to revert to backup: {err}")); + } + + let current_exe = env::current_exe().map_err(|err| { + eco_format!("failed to locate path of the running executable: {err}") + })?; + + fs::copy(current_exe, &backup_path) + .map_err(|err| eco_format!("failed to create backup: {err}"))?; + + let release = Release::from_tag(command.version)?; + if !update_needed(&release)? && !command.force { + eprintln!("Already up-to-date."); + return Ok(()); + } + + let binary_data = release.download_binary(needed_asset()?)?; + let mut temp_exe = NamedTempFile::new() + .map_err(|err| eco_format!("failed to create temporary file: {err}"))?; + temp_exe + .write_all(&binary_data) + .map_err(|err| eco_format!("failed to write binary data: {err}"))?; + + self_replace::self_replace(&temp_exe).map_err(|err| { + fs::remove_file(&temp_exe).ok(); + eco_format!("failed to self-replace running executable: {err}") + }) +} + +/// Assets belonging to a GitHub release. +/// +/// Primarily used to download pre-compiled Typst CLI binaries. +#[derive(Debug, Deserialize)] +struct Asset { + name: String, + browser_download_url: String, +} + +/// A GitHub release. +#[derive(Debug, Deserialize)] +struct Release { + tag_name: String, + assets: Vec<Asset>, +} + +impl Release { + /// Download the target release, or latest if version is `None`, from the + /// Typst repository. + pub fn from_tag(tag: Option<Version>) -> StrResult<Release> { + let url = match tag { + Some(tag) => format!( + "https://api.github.com/repos/{}/{}/releases/tags/v{}", + TYPST_GITHUB_ORG, TYPST_REPO, tag + ), + None => format!( + "https://api.github.com/repos/{}/{}/releases/latest", + TYPST_GITHUB_ORG, TYPST_REPO + ), + }; + + match ureq::get(&url).call() { + Ok(response) => response + .into_json() + .map_err(|err| eco_format!("unable to parse JSON response: {err}")), + Err(ureq::Error::Status(404, _)) => { + bail!("release not found (searched at {url})") + } + Err(_) => bail!("failed to download release (network failed)"), + } + } + + /// Download the binary from a given [`Release`] and select the + /// corresponding asset for this target platform, returning the raw binary + /// data. + pub fn download_binary(&self, asset_name: &str) -> StrResult<Vec<u8>> { + let asset = self + .assets + .iter() + .find(|a| a.name.starts_with(asset_name)) + .ok_or("could not find release for your target platform")?; + + eprintln!("Downloading release ..."); + let response = match ureq::get(&asset.browser_download_url).call() { + Ok(response) => response, + Err(ureq::Error::Status(404, _)) => { + bail!("asset not found (searched for {})", asset.name); + } + Err(_) => bail!("failed to load asset (network failed)"), + }; + + let mut data = Vec::new(); + response + .into_reader() + .read_to_end(&mut data) + .map_err(|err| eco_format!("failed to read response buffer: {err}"))?; + + if asset_name.contains("windows") { + extract_binary_from_zip(&data, asset_name) + } else { + extract_binary_from_tar_xz(&data) + } + } +} + +/// Extract the Typst binary from a ZIP archive. +fn extract_binary_from_zip(data: &[u8], asset_name: &str) -> StrResult<Vec<u8>> { + let mut archive = ZipArchive::new(Cursor::new(data)) + .map_err(|err| eco_format!("failed to extract ZIP archive: {err}"))?; + + let mut file = archive + .by_name(&format!("{asset_name}/typst.exe")) + .map_err(|_| "ZIP archive did not contain Typst binary")?; + + let mut buffer = vec![]; + file.read_to_end(&mut buffer).map_err(|err| { + eco_format!("failed to read binary data from ZIP archive: {err}") + })?; + + Ok(buffer) +} + +/// Extract the Typst binary from a `.tar.xz` archive. +fn extract_binary_from_tar_xz(data: &[u8]) -> StrResult<Vec<u8>> { + let mut archive = tar::Archive::new(XzDecoder::new(Cursor::new(data))); + + let mut file = archive + .entries() + .map_err(|err| eco_format!("failed to extract tar.xz archive: {err}"))? + .filter_map(Result::ok) + .find(|e| e.path().unwrap_or_default().ends_with("typst")) + .ok_or("tar.xz archive did not contain Typst binary")?; + + let mut buffer = vec![]; + file.read_to_end(&mut buffer).map_err(|err| { + eco_format!("failed to read binary data from tar.xz archive: {err}") + })?; + + Ok(buffer) +} + +/// Determine what asset to download according to the target platform the CLI +/// is running on. +fn needed_asset() -> StrResult<&'static str> { + Ok(match env!("TARGET") { + "x86_64-unknown-linux-gnu" => "typst-x86_64-unknown-linux-musl", + "x86_64-unknown-linux-musl" => "typst-x86_64-unknown-linux-musl", + "aarch64-unknown-linux-musl" => "typst-aarch64-unknown-linux-musl", + "aarch64-unknown-linux-gnu" => "typst-aarch64-unknown-linux-musl", + "armv7-unknown-linux-musleabi" => "typst-armv7-unknown-linux-musleabi", + "x86_64-apple-darwin" => "typst-x86_64-apple-darwin", + "aarch64-apple-darwin" => "typst-aarch64-apple-darwin", + "x86_64-pc-windows-msvc" => "typst-x86_64-pc-windows-msvc", + target => bail!("unsupported target: {target}"), + }) +} + +/// Compare the release version to the CLI version to see if an update is needed. +fn update_needed(release: &Release) -> StrResult<bool> { + let current_tag: Version = env!("CARGO_PKG_VERSION").parse().unwrap(); + let new_tag: Version = release + .tag_name + .strip_prefix('v') + .unwrap_or(&release.tag_name) + .parse() + .map_err(|_| "release tag not in semver format")?; + + Ok(new_tag > current_tag) +} + +/// Path to a potential backup file. +/// +/// The backup will be placed in one of the following directories, depending on +/// the platform: +/// - `$XDG_STATE_HOME` or `~/.local/state` on Linux +/// - `$XDG_DATA_HOME` or `~/.local/share` if the above path isn't available +/// - `~/Library/Application Support` on macOS +/// - `%APPDATA%` on Windows +fn backup_path() -> StrResult<PathBuf> { + #[cfg(target_os = "linux")] + let root_backup_dir = dirs::state_dir() + .or_else(|| dirs::data_dir()) + .ok_or("unable to locate local data or state directory")?; + + #[cfg(not(target_os = "linux"))] + let root_backup_dir = + dirs::data_dir().ok_or("unable to locate local data directory")?; + + let backup_dir = root_backup_dir.join("typst"); + + fs::create_dir_all(&backup_dir) + .map_err(|err| eco_format!("failed to create backup directory: {err}"))?; + + Ok(backup_dir.join("typst_backup.part")) +} |
