summaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorjimvdl <jimvdlind@gmail.com>2023-08-26 20:44:58 +0200
committerGitHub <noreply@github.com>2023-08-26 20:44:58 +0200
commit7bdf1f57b09ea605045254013a8200373451baf0 (patch)
tree0d7264dc2ca3aaf41b31e5226d8562cb4f68121e /crates
parent879182059020e4669efae2296e98cdc8360a3bc2 (diff)
Let the CLI `typst update` itself without a package manager (#1887)
Diffstat (limited to 'crates')
-rw-r--r--crates/typst-cli/Cargo.toml11
-rw-r--r--crates/typst-cli/build.rs2
-rw-r--r--crates/typst-cli/src/args.rs21
-rw-r--r--crates/typst-cli/src/main.rs17
-rw-r--r--crates/typst-cli/src/update.rs246
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 < &current_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"))
+}