1use std::io::Write;
12use std::process::{Command, Stdio};
13use std::sync::mpsc::{self, Receiver};
14use std::thread;
15
16use crate::remote::RemoteTarget;
17
18const RELEASES_URL: &str = "https://github.com/akivajp/rigtop/releases/download";
20
21pub fn target_version() -> &'static str {
23 env!("CARGO_PKG_VERSION")
24}
25
26pub fn agent_is_older(agent_version: &str) -> bool {
29 match (
30 parse_version(agent_version),
31 parse_version(target_version()),
32 ) {
33 (Some(agent), Some(ours)) => agent < ours,
34 _ => false,
35 }
36}
37
38fn parse_version(version: &str) -> Option<Vec<u64>> {
40 let core = version.split(['-', '+']).next()?;
41 core.split('.').map(|part| part.parse().ok()).collect()
42}
43
44fn agent_binary(agent_command: &str) -> Option<&str> {
47 agent_command
48 .split_whitespace()
49 .next()
50 .filter(|program| program.contains('/'))
51}
52
53fn shell_quote(text: &str) -> String {
55 format!("'{}'", text.replace('\'', r"'\''"))
56}
57
58pub fn update_script(version: &str, agent_command: &str) -> String {
61 let tag = shell_quote(&format!("v{version}"));
62 let url = shell_quote(&format!("{RELEASES_URL}/v{version}/install.sh"));
63 let find = match agent_binary(agent_command) {
65 Some(path) => match path.strip_prefix("~/") {
66 Some(rest) => format!("bin=\"$HOME\"/{}", shell_quote(rest)),
67 None => format!("bin={}", shell_quote(path)),
68 },
69 None => "bin=$(command -v rigtop 2>/dev/null || true)\n\
71 [ -n \"$bin\" ] || [ ! -x \"$HOME/.cargo/bin/rigtop\" ] || bin=\"$HOME/.cargo/bin/rigtop\""
72 .to_owned(),
73 };
74 format!(
75 r#"set -eu
76{find}
77if [ -z "$bin" ] || [ ! -e "$bin" ]; then
78 echo "rigtop update: rigtop not found on this host" >&2
79 exit 1
80fi
81case "$bin" in
82 */.cargo/bin/rigtop)
83 echo "rigtop update: $bin was installed with cargo; update it with: cargo install rigtop --version {version} --locked" >&2
84 exit 1
85 ;;
86esac
87dir=$(dirname "$bin")
88if [ ! -w "$dir" ]; then
89 echo "rigtop update: $dir is not writable (a package manager's copy?); update it with the package manager or the .deb of v{version}" >&2
90 exit 1
91fi
92# Keep rigtop-gui only where it was installed.
93gui=--tui-only
94[ ! -x "$dir/rigtop-gui" ] || gui=
95script=$(mktemp)
96trap 'rm -f "$script"' EXIT
97if command -v curl >/dev/null 2>&1; then
98 curl -fsSL --retry 3 {url} -o "$script"
99elif command -v wget >/dev/null 2>&1; then
100 wget -q -O "$script" {url}
101else
102 echo "rigtop update: needs curl or wget" >&2
103 exit 1
104fi
105sh "$script" --version {tag} --bin-dir "$dir" $gui
106"#
107 )
108}
109
110pub fn unsupported_reason(os: &str) -> Option<String> {
113 os.starts_with("Windows").then(|| {
114 "on Windows, update rigtop there with `scoop update rigtop`, or with the zip \
115 from the releases page"
116 .to_owned()
117 })
118}
119
120pub fn describe(host: &str, agent_version: &str) -> String {
122 format!(
123 "Update rigtop on {host} from {agent_version} to {version}? Over SSH, this \
124 downloads the v{version} release from GitHub, checks it and replaces the \
125 rigtop the agent runs (when it is in a directory you can write to; copies \
126 from cargo or a package manager are left alone). The connection then restarts.",
127 version = target_version()
128 )
129}
130
131pub struct UpdateJob {
133 result: Receiver<Result<String, String>>,
134}
135
136impl UpdateJob {
137 pub fn start(target: &RemoteTarget) -> Self {
139 let agent_command = target.args.last().cloned().unwrap_or_default();
140 let script = update_script(target_version(), &agent_command);
141 let mut args = target.args.clone();
143 if let Some(last) = args.last_mut() {
144 *last = "sh -s".to_owned();
145 }
146 let program = target.program.clone();
147 let (tx, result) = mpsc::channel();
148 thread::Builder::new()
149 .name("rigtop-update".to_owned())
150 .spawn(move || {
151 let result = run_script(&program, &args, &script).map(|line| {
152 line.strip_prefix("rigtop install: done: ")
154 .map_or(line.clone(), str::to_owned)
155 });
156 let _ = tx.send(result);
157 })
158 .expect("spawn update thread");
159 Self { result }
160 }
161
162 pub fn poll(&self) -> Option<Result<String, String>> {
165 self.result.try_recv().ok()
166 }
167}
168
169fn run_script(program: &str, args: &[String], script: &str) -> Result<String, String> {
172 let mut command = Command::new(program);
173 command
174 .args(args)
175 .stdin(Stdio::piped())
176 .stdout(Stdio::piped())
177 .stderr(Stdio::piped());
178 #[cfg(windows)]
179 {
180 use std::os::windows::process::CommandExt;
181 command.creation_flags(0x0800_0000);
183 }
184 let mut child = command
185 .spawn()
186 .map_err(|err| format!("cannot start {program}: {err}"))?;
187 if let Some(mut stdin) = child.stdin.take() {
188 let _ = stdin.write_all(script.as_bytes());
190 }
191 let output = child
192 .wait_with_output()
193 .map_err(|err| format!("{program} failed: {err}"))?;
194 let last_line = |bytes: &[u8]| {
195 String::from_utf8_lossy(bytes)
196 .lines()
197 .rev()
198 .map(str::trim)
199 .find(|line| !line.is_empty())
200 .map(str::to_owned)
201 };
202 if output.status.success() {
203 Ok(last_line(&output.stdout).unwrap_or_else(|| "done".to_owned()))
204 } else {
205 Err(last_line(&output.stderr)
206 .or_else(|| last_line(&output.stdout))
207 .unwrap_or_else(|| format!("{program} exited with {}", output.status)))
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn versions_compare_by_number() {
217 assert_eq!(parse_version("0.10.1"), Some(vec![0, 10, 1]));
218 assert_eq!(parse_version("1.2.0-rc.1"), Some(vec![1, 2, 0]));
219 assert_eq!(parse_version(""), None);
220 assert!(agent_is_older("0.0.9"));
221 assert!(!agent_is_older(target_version()));
222 assert!(!agent_is_older("999.0.0"));
223 assert!(!agent_is_older(""), "unknown versions are not offered");
224 }
225
226 #[test]
227 fn windows_hosts_get_directions_instead() {
228 assert!(unsupported_reason("Windows 11 Pro").is_some());
229 assert!(unsupported_reason("Linux (Ubuntu 24.04)").is_none());
230 }
231
232 #[test]
233 fn script_uses_the_agent_command_path() {
234 assert!(update_script("1.2.3", "rigtop --agent").contains("command -v rigtop"));
235 let script = update_script("1.2.3", "/opt/my rig/rigtop --agent");
236 assert!(script.contains("bin='/opt/my'"));
238 let script = update_script("1.2.3", "~/bin/rigtop --agent");
239 assert!(script.contains(r#"bin="$HOME"/'bin/rigtop'"#));
240 assert!(
241 script.contains(
242 "'https://github.com/akivajp/rigtop/releases/download/v1.2.3/install.sh'"
243 )
244 );
245 assert!(script.contains("--version 'v1.2.3'"));
246 }
247
248 #[cfg(unix)]
251 #[test]
252 fn script_installs_next_to_the_running_rigtop() {
253 use std::fs;
254 use std::os::unix::fs::PermissionsExt;
255
256 let tmp = tempfile::tempdir().unwrap();
257 let bin = tmp.path().join("bin");
258 let tools = tmp.path().join("tools");
259 fs::create_dir_all(&bin).unwrap();
260 fs::create_dir_all(&tools).unwrap();
261 let executable = |path: &std::path::Path, text: &str| {
262 fs::write(path, text).unwrap();
263 fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
264 };
265 executable(&bin.join("rigtop"), "#!/bin/sh\n");
266 executable(
268 &tools.join("curl"),
269 "#!/bin/sh\nwhile [ $# -gt 1 ]; do [ \"$1\" = -o ] && out=$2; shift; done\n\
270 printf 'echo installed \"$@\"\\n' > \"$out\"\n",
271 );
272 let path = format!("{}:{}:/usr/bin:/bin", bin.display(), tools.display());
273 let run = |script: &str| {
274 run_script(
275 "env",
276 &[format!("PATH={path}"), "sh".to_owned(), "-s".to_owned()],
277 script,
278 )
279 };
280
281 let output = run(&update_script("1.2.3", "rigtop --agent")).unwrap();
282 assert_eq!(
283 output,
284 format!(
285 "installed --version v1.2.3 --bin-dir {} --tui-only",
286 bin.display()
287 )
288 );
289
290 fs::set_permissions(&bin, fs::Permissions::from_mode(0o555)).unwrap();
292 let result = run(&update_script("1.2.3", "rigtop --agent"));
293 fs::set_permissions(&bin, fs::Permissions::from_mode(0o755)).unwrap();
294 if let Err(error) = result {
296 assert!(error.contains("is not writable"), "{error}");
297 }
298
299 let cargo = tmp.path().join(".cargo/bin");
301 fs::create_dir_all(&cargo).unwrap();
302 executable(&cargo.join("rigtop"), "#!/bin/sh\n");
303 let command = format!("{}/rigtop --agent", cargo.display());
304 let error = run(&update_script("1.2.3", &command)).unwrap_err();
305 assert!(
306 error.contains("cargo install rigtop --version 1.2.3"),
307 "{error}"
308 );
309
310 let error = run(&update_script("1.2.3", "/nowhere/rigtop --agent")).unwrap_err();
311 assert!(error.contains("not found"), "{error}");
312 }
313}