Skip to content

Commit f1f306c

Browse files
committed
feat: add self-update command for in-place pvm upgrades
Adds 'pvm self-update' (check only) and 'pvm self-update --apply' (check + interactive Confirm + atomic binary replace) using the GitHub releases API and the same release artifacts that install.sh publishes. Extracts shared HTTP client, target-triple detection, progress bar, and stream-to-tempfile helpers from network.rs so the new command reuses them instead of duplicating ~50 lines.
1 parent 851380d commit f1f306c

5 files changed

Lines changed: 268 additions & 33 deletions

File tree

src/cli.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ pub enum Commands {
5252
/// Initialize a .php-version file in the current directory
5353
#[clap(name = "init")]
5454
Init(commands::init::Init),
55+
56+
/// Check for and apply updates to pvm itself
57+
#[clap(name = "self-update")]
58+
SelfUpdate(commands::self_update::SelfUpdate),
5559
}
5660

5761
impl Commands {
@@ -65,6 +69,7 @@ impl Commands {
6569
Self::Current(cmd) => cmd.call().await,
6670
Self::Uninstall(cmd) => cmd.call().await,
6771
Self::Init(cmd) => cmd.call().await,
72+
Self::SelfUpdate(cmd) => cmd.call().await,
6873
}
6974
}
7075
}

src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ pub mod init;
44
pub mod install;
55
pub mod ls;
66
pub mod ls_remote;
7+
pub mod self_update;
78
pub mod uninstall;
89
pub mod use_cmd;

src/commands/self_update.rs

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
use crate::network;
2+
use anyhow::{Context, Result};
3+
use clap::Parser;
4+
use colored::Colorize;
5+
use serde::Deserialize;
6+
use std::io::Write;
7+
8+
const GITHUB_REPO: &str = "WebProject-xyz/php-version-manager";
9+
10+
/// Check for and apply pvm updates
11+
#[derive(Parser, Debug)]
12+
pub struct SelfUpdate {
13+
/// Apply the update if a newer version is available (asks for confirmation, default yes)
14+
#[arg(long)]
15+
pub apply: bool,
16+
}
17+
18+
#[derive(Deserialize)]
19+
struct GhRelease {
20+
tag_name: String,
21+
}
22+
23+
fn current_version() -> Result<semver::Version> {
24+
let raw = env!("PVM_VERSION");
25+
let token = raw.split_whitespace().next().unwrap_or(raw);
26+
let trimmed = token.trim_start_matches('v');
27+
// git-describe extras (e.g. "-2-gabc") are not valid semver; drop them.
28+
let core = trimmed.split('-').next().unwrap_or(trimmed);
29+
semver::Version::parse(core).with_context(|| {
30+
format!(
31+
"Failed to parse current pvm version '{}' from '{}'",
32+
core, raw
33+
)
34+
})
35+
}
36+
37+
fn parse_remote_version(tag: &str) -> Result<semver::Version> {
38+
let trimmed = tag.trim_start_matches('v');
39+
semver::Version::parse(trimmed)
40+
.with_context(|| format!("Failed to parse remote release tag '{}'", tag))
41+
}
42+
43+
async fn fetch_latest_release() -> Result<GhRelease> {
44+
let url = format!(
45+
"https://api.github.com/repos/{}/releases/latest",
46+
GITHUB_REPO
47+
);
48+
let release = network::http_client()?
49+
.get(&url)
50+
.header("Accept", "application/vnd.github+json")
51+
.send()
52+
.await
53+
.context("Failed to query GitHub releases API")?
54+
.error_for_status()
55+
.context("GitHub releases API returned an error")?
56+
.json::<GhRelease>()
57+
.await
58+
.context("Failed to parse GitHub release JSON")?;
59+
Ok(release)
60+
}
61+
62+
async fn download_and_replace(tag: &str) -> Result<()> {
63+
let target = network::get_target_triple()?;
64+
let asset = format!("pvm-{}.tar.gz", target);
65+
let url = format!(
66+
"https://github.com/{}/releases/download/{}/{}",
67+
GITHUB_REPO, tag, asset
68+
);
69+
70+
let current_exe =
71+
std::env::current_exe().context("Failed to determine current executable path")?;
72+
let exe_dir = current_exe
73+
.parent()
74+
.context("Current executable has no parent directory")?;
75+
76+
println!("{} Downloading {}...", "↻".blue(), url);
77+
78+
let response = network::http_client()?
79+
.get(&url)
80+
.send()
81+
.await
82+
.context("Failed to download release archive")?
83+
.error_for_status()
84+
.context("Server returned an error when downloading release")?;
85+
86+
let pb = network::build_download_progress_bar(response.content_length())?;
87+
let tmp = network::stream_to_tempfile(response, &pb).await?;
88+
pb.finish_and_clear();
89+
90+
// Stage the new binary in the same directory as the current exe so the rename is atomic
91+
// (cross-filesystem renames would fail otherwise).
92+
let mut staged = tempfile::Builder::new()
93+
.prefix(".pvm-update-")
94+
.tempfile_in(exe_dir)
95+
.context("Failed to create staging file next to current executable")?;
96+
97+
{
98+
let tar = flate2::read::GzDecoder::new(tmp);
99+
let mut archive = tar::Archive::new(tar);
100+
let mut found = false;
101+
for entry in archive
102+
.entries()
103+
.context("Failed to read archive entries")?
104+
{
105+
let mut entry = entry.context("Failed to read archive entry")?;
106+
let path = entry.path().context("Invalid entry path")?.into_owned();
107+
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
108+
if name == "pvm" {
109+
std::io::copy(&mut entry, staged.as_file_mut())
110+
.context("Failed to extract pvm binary from archive")?;
111+
staged
112+
.as_file_mut()
113+
.flush()
114+
.context("Failed to flush staged binary")?;
115+
found = true;
116+
break;
117+
}
118+
}
119+
if !found {
120+
anyhow::bail!("Release archive did not contain a `pvm` binary");
121+
}
122+
}
123+
124+
#[cfg(unix)]
125+
{
126+
use std::os::unix::fs::PermissionsExt;
127+
std::fs::set_permissions(staged.path(), std::fs::Permissions::from_mode(0o755))
128+
.context("Failed to set permissions on staged binary")?;
129+
}
130+
131+
// Atomic rename. On Unix this is safe even while the current binary is executing — the
132+
// kernel keeps the old inode alive through open fds until the process exits.
133+
staged
134+
.persist(&current_exe)
135+
.with_context(|| format!("Failed to replace current executable {:?}", current_exe))?;
136+
137+
Ok(())
138+
}
139+
140+
impl SelfUpdate {
141+
pub async fn call(self) -> Result<()> {
142+
let current = current_version()?;
143+
let current_str = current.to_string();
144+
println!("{} Current version: {}", "↻".blue(), current_str.bold());
145+
println!("{} Checking GitHub for the latest release...", "↻".blue());
146+
147+
let release = fetch_latest_release().await?;
148+
let remote = parse_remote_version(&release.tag_name)?;
149+
150+
if remote <= current {
151+
println!("{} pvm is up to date ({})", "✓".green(), current_str.bold());
152+
return Ok(());
153+
}
154+
155+
let remote_str = remote.to_string();
156+
println!(
157+
"{} New version available: {} → {}",
158+
"💡".yellow(),
159+
current_str.bold(),
160+
remote_str.bold()
161+
);
162+
163+
if !self.apply {
164+
println!(
165+
"{} Run `{}` to install it.",
166+
"💡".yellow(),
167+
"pvm self-update --apply".bold()
168+
);
169+
return Ok(());
170+
}
171+
172+
let theme = dialoguer::theme::ColorfulTheme::default();
173+
let confirmed = dialoguer::Confirm::with_theme(&theme)
174+
.with_prompt(format!("Update pvm to {}?", remote_str).bold().to_string())
175+
.default(true)
176+
.interact_opt()
177+
.unwrap_or(Some(false))
178+
.unwrap_or(false);
179+
180+
if !confirmed {
181+
println!("{} Update cancelled.", "✗".red());
182+
return Ok(());
183+
}
184+
185+
download_and_replace(&release.tag_name).await?;
186+
println!(
187+
"{} Successfully updated pvm to {}",
188+
"✓".green(),
189+
remote_str.bold()
190+
);
191+
println!(
192+
"{} Restart your shell or re-run `{}` to pick up the new binary.",
193+
"💡".yellow(),
194+
"pvm env".bold()
195+
);
196+
197+
Ok(())
198+
}
199+
}

src/network.rs

Lines changed: 45 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ use std::time::Duration;
1717
const CACHE_DURATION: Duration = Duration::from_secs(24 * 60 * 60); // 24 hours
1818
const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
1919
const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(300);
20+
const USER_AGENT: &str = concat!("pvm/", env!("CARGO_PKG_VERSION"));
2021

21-
fn http_client() -> Result<Client> {
22+
pub(crate) fn http_client() -> Result<Client> {
2223
Client::builder()
2324
.connect_timeout(HTTP_CONNECT_TIMEOUT)
2425
.timeout(HTTP_REQUEST_TIMEOUT)
26+
.user_agent(USER_AGENT)
2527
.build()
2628
.context("Failed to initialize HTTP client")
2729
}
@@ -32,7 +34,7 @@ struct RemoteFile {
3234
is_dir: bool,
3335
}
3436

35-
fn get_target_triple() -> Result<&'static str> {
37+
pub(crate) fn get_target_triple() -> Result<&'static str> {
3638
use std::env::consts::{ARCH, OS};
3739
match (OS, ARCH) {
3840
("linux", "x86_64") => Ok("linux-x86_64"),
@@ -43,6 +45,45 @@ fn get_target_triple() -> Result<&'static str> {
4345
}
4446
}
4547

48+
pub(crate) fn build_download_progress_bar(total_size: Option<u64>) -> Result<ProgressBar> {
49+
let pb = if let Some(size) = total_size {
50+
let pb = ProgressBar::new(size);
51+
pb.set_style(ProgressStyle::default_bar()
52+
.template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})")?
53+
.progress_chars("#>-"));
54+
pb
55+
} else {
56+
let pb = ProgressBar::new_spinner();
57+
pb.set_style(ProgressStyle::default_spinner().template(
58+
"{spinner:.green} [{elapsed_precise}] {bytes} downloaded ({bytes_per_sec})",
59+
)?);
60+
pb
61+
};
62+
Ok(pb)
63+
}
64+
65+
// Stream the response into a temp file to avoid materializing the whole archive in memory,
66+
// returning a handle rewound to position 0 so the caller can feed it to a decoder.
67+
pub(crate) async fn stream_to_tempfile(
68+
response: reqwest::Response,
69+
pb: &ProgressBar,
70+
) -> Result<File> {
71+
let mut tmp = tempfile::tempfile().context("Failed to create temporary archive file")?;
72+
let mut downloaded: u64 = 0;
73+
let mut stream = response.bytes_stream();
74+
while let Some(item) = stream.next().await {
75+
let chunk = item.context("Error while downloading chunk")?;
76+
tmp.write_all(&chunk)
77+
.context("Failed to write archive chunk to temp file")?;
78+
downloaded += chunk.len() as u64;
79+
pb.set_position(downloaded);
80+
}
81+
tmp.flush().context("Failed to flush temp archive file")?;
82+
tmp.seek(SeekFrom::Start(0))
83+
.context("Failed to rewind temp archive file")?;
84+
Ok(tmp)
85+
}
86+
4687
pub async fn get_available_versions() -> Result<Vec<(String, Vec<String>)>> {
4788
let pvm_dir = crate::fs::get_pvm_dir()?;
4889
let target = get_target_triple()?;
@@ -222,37 +263,8 @@ pub async fn download_and_extract(
222263
resolved_version, package
223264
))?;
224265

225-
let total_size = response.content_length();
226-
227-
let pb = if let Some(size) = total_size {
228-
let pb = ProgressBar::new(size);
229-
pb.set_style(ProgressStyle::default_bar()
230-
.template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})")?
231-
.progress_chars("#>-"));
232-
pb
233-
} else {
234-
let pb = ProgressBar::new_spinner();
235-
pb.set_style(ProgressStyle::default_spinner().template(
236-
"{spinner:.green} [{elapsed_precise}] {bytes} downloaded ({bytes_per_sec})",
237-
)?);
238-
pb
239-
};
240-
241-
// Stream the archive into a temp file to avoid materializing the whole tarball in memory.
242-
let mut tmp = tempfile::tempfile().context("Failed to create temporary archive file")?;
243-
let mut downloaded: u64 = 0;
244-
let mut stream = response.bytes_stream();
245-
while let Some(item) = stream.next().await {
246-
let chunk = item.context("Error while downloading chunk")?;
247-
tmp.write_all(&chunk)
248-
.context("Failed to write archive chunk to temp file")?;
249-
downloaded += chunk.len() as u64;
250-
pb.set_position(downloaded);
251-
}
252-
tmp.flush().context("Failed to flush temp archive file")?;
253-
tmp.seek(SeekFrom::Start(0))
254-
.context("Failed to rewind temp archive file")?;
255-
266+
let pb = build_download_progress_bar(response.content_length())?;
267+
let tmp = stream_to_tempfile(response, &pb).await?;
256268
pb.finish_with_message(format!("Downloaded package {}", package));
257269

258270
let tar = GzDecoder::new(tmp);

tests/cli.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,24 @@ fn test_version_short() {
2727
.stdout(predicate::str::contains("pvm"));
2828
}
2929

30+
#[test]
31+
fn test_self_update_help() {
32+
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm");
33+
cmd.arg("self-update").arg("--help");
34+
cmd.assert()
35+
.success()
36+
.stdout(predicate::str::contains("--apply"));
37+
}
38+
39+
#[test]
40+
fn test_help_lists_self_update() {
41+
let mut cmd = assert_cmd::cargo::cargo_bin_cmd!("pvm");
42+
cmd.arg("--help");
43+
cmd.assert()
44+
.success()
45+
.stdout(predicate::str::contains("self-update"));
46+
}
47+
3048
#[test]
3149
fn test_ls_empty() {
3250
let temp_dir = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)