1use std::io::{BufRead, IsTerminal, Write};
5use std::path::{Path, PathBuf};
6use std::process::{Command, Output};
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9use serde_json::{json, Value};
10use superfluid_adapter_kit::install::{fetch, single_top, unpack, version_key, Asset};
11
12use crate::runtime_pick::RuntimeId;
13use crate::runtimes::Catalog;
14
15pub const REPO: &str = "basecompute/superfluid";
16const LISTING: &str = "https://api.github.com/repos/basecompute/superfluid/releases?per_page=20";
17pub const HOLD_OFF_DAYS: i64 = 3;
19const CHECK_EVERY: Duration = Duration::from_secs(24 * 60 * 60);
20pub const NO_CHECK_ENV: &str = "SUPERFLUID_NO_UPDATE_CHECK";
21pub const LISTING_ENV: &str = "SUPERFLUID_RELEASES";
23const LINKED: &[&str] = &["superfluid", "superfluid-workerd", "superfluid-noded"];
24pub const INSTALLED_BY: &str = "installed-by";
27const BASERT_STAMP: &str = ".release";
30const PROBE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
33
34pub const HELP: &str = "Update superfluid itself, or switch between the versions installed here.
35
36Usage:
37 superfluid self update [--version <v>] [--dry-run] [--yes]
38 Install the newest release that fits this machine and make it current
39 superfluid self check Ask whether a newer release exists, without installing
40 superfluid self list The versions installed here, and which is current
41 superfluid self use <v> Make an installed version current (the one an update replaced stays)
42 superfluid self install --from <dir> [--prefix <dir>] Place an unpacked release here; what the install script runs
43
44Releases live side by side under $SUPERFLUID_HOME/versions/<v>, with a `current` link that the
45`superfluid` on PATH goes through. An update unpacks the new release beside the old one, proves
46it starts, flips the link, keeps the version it replaced and removes older ones (a version with
47a `keep` file inside, or one a running server started from, stays). A running server keeps the
48version it started with until it is started again.
49
50With a baseRT engine here, a release must declare the engine's ABI number; with llama.cpp or
51MLX only, the newest release fits. A superfluid installed by a package manager or cargo is
52updated the way it was installed: for Homebrew, a global npm, pipx, uv and cargo this command
53runs that manager's update (asking first; --yes runs it without asking, and is needed without
54a terminal), and for pip in an environment or a project's npm dependency it names the command.
55The superfluid basert installs for `basert serve` is basert's: this command runs
56`basert superfluid install`, which picks the release that serves basert's engine.
57
58`superfluid serve` checks for a newer release once a day, after the server is up, and prints
59one line; SUPERFLUID_NO_UPDATE_CHECK=1 or --offline turns that off. A release is announced a
60few days after it is published.
61";
62
63pub fn target() -> Option<&'static str> {
65 match (std::env::consts::OS, std::env::consts::ARCH) {
66 ("macos", "aarch64") => Some("aarch64-apple-darwin"),
67 ("linux", "aarch64") => Some("aarch64-unknown-linux-gnu"),
68 ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"),
69 _ => None,
70 }
71}
72
73pub fn running() -> &'static str {
74 env!("CARGO_PKG_VERSION")
75}
76
77
78fn is_version(v: &str) -> bool {
81 v.starts_with(|c: char| c.is_ascii_digit()) && v.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
86enum PreRelease {
87 Number(u64),
88 Word(String),
89}
90
91fn release_order(v: &str) -> (Vec<u64>, bool, Vec<PreRelease>) {
95 let v = v.split('+').next().unwrap_or(v);
96 let (numbers, pre) = match v.split_once('-') {
97 Some((numbers, pre)) => (numbers, Some(pre)),
98 None => (v, None),
99 };
100 let parts = pre
101 .map(|p| p.split('.').map(|id| id.parse().map(PreRelease::Number).unwrap_or_else(|_| PreRelease::Word(id.to_string()))).collect())
102 .unwrap_or_default();
103 (version_key(numbers), pre.is_none(), parts)
104}
105
106pub struct Versions {
108 pub dir: PathBuf,
109}
110
111impl Versions {
112 pub fn of(home: &Path) -> Versions {
113 Versions { dir: home.join("versions") }
114 }
115
116 pub fn version_dir(&self, v: &str) -> PathBuf {
117 self.dir.join(v)
118 }
119
120 fn binary(&self, v: &str) -> PathBuf {
121 self.version_dir(v).join("bin").join("superfluid")
122 }
123
124 pub fn current(&self) -> Option<String> {
125 let target = std::fs::read_link(self.dir.join("current")).ok()?;
126 target.file_name().and_then(|n| n.to_str()).map(str::to_string)
127 }
128
129 pub fn previous(&self) -> Option<String> {
130 let v = std::fs::read_to_string(self.dir.join("previous")).ok()?.trim().to_string();
131 (!v.is_empty() && self.binary(&v).is_file()).then_some(v)
132 }
133
134 pub fn on_disk(&self) -> Vec<String> {
136 let mut all: Vec<String> = std::fs::read_dir(&self.dir)
137 .map(|rd| {
138 rd.flatten()
139 .filter_map(|e| e.file_name().to_str().map(str::to_string))
140 .filter(|n| !n.starts_with('.') && n != "current" && n.chars().next().is_some_and(|c| c.is_ascii_digit()))
141 .filter(|n| self.binary(n).is_file())
142 .collect()
143 })
144 .unwrap_or_default();
145 all.sort_by_key(|v| std::cmp::Reverse(release_order(v)));
146 all
147 }
148
149 pub fn prefix(&self) -> Option<PathBuf> {
150 let p = std::fs::read_to_string(self.dir.join("prefix")).ok()?.trim().to_string();
151 (!p.is_empty()).then(|| PathBuf::from(p))
152 }
153
154 pub fn set_current(&self, v: &str) -> Result<(), String> {
156 if !self.binary(v).is_file() {
157 return Err(format!("{} is not an installed version (see `superfluid self list`)", v));
158 }
159 if let Some(was) = self.current().filter(|was| was != v) {
160 std::fs::write(self.dir.join("previous"), format!("{was}\n")).map_err(|e| format!("{}: {e}", self.dir.display()))?;
161 }
162 let tmp = self.dir.join(".current.tmp");
163 let _ = std::fs::remove_file(&tmp);
164 std::os::unix::fs::symlink(v, &tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
165 std::fs::rename(&tmp, self.dir.join("current")).map_err(|e| format!("{}: {e}", self.dir.join("current").display()))
166 }
167
168 pub fn prune(&self) -> Result<Vec<String>, String> {
170 let (current, previous) = (self.current(), self.previous());
171 let mut gone = Vec::new();
172 for v in self.on_disk() {
173 let dir = self.version_dir(&v);
174 if Some(&v) == current.as_ref() || Some(&v) == previous.as_ref() || dir.join("keep").exists() || running_from(&dir) {
175 continue;
176 }
177 std::fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
178 gone.push(v);
179 }
180 Ok(gone)
181 }
182
183 fn legacy_files(&self, prefix: &Path) -> Vec<&'static str> {
185 LINKED.iter().copied().filter(|name| prefix.join(name).symlink_metadata().is_ok_and(|m| m.file_type().is_file())).collect()
186 }
187
188 pub fn link(&self, prefix: &Path, replace_files: bool) -> Result<Vec<String>, String> {
194 std::fs::create_dir_all(prefix).map_err(|e| format!("{}: {e}", prefix.display()))?;
195 std::fs::write(self.dir.join("prefix"), format!("{}\n", prefix.display())).map_err(|e| format!("{}: {e}", self.dir.display()))?;
196 let mut notes = Vec::new();
197 let pending = self.dir.join("migrating");
198 let legacy = self.legacy_files(prefix);
199 let migrating = !legacy.is_empty() && (replace_files || pending.exists());
200 let mut holding: Vec<(i32, u64)> = match migrating {
202 true => std::fs::read_to_string(&pending)
203 .unwrap_or_default()
204 .lines()
205 .skip(1)
206 .filter_map(|l| l.split_once(' ').and_then(|(p, at)| Some((p.parse().ok()?, at.parse().ok()?))))
207 .filter(|&(p, at)| started_at(p) == Some(at))
208 .collect(),
209 false => Vec::new(),
210 };
211 if migrating {
212 for p in processes_from(prefix) {
213 if !holding.contains(&p) {
214 holding.push(p);
215 }
216 }
217 }
218 let in_use = !holding.is_empty();
219 let replace: Vec<&str> = match (migrating, in_use) {
220 (true, true) => vec!["superfluid"],
221 (true, false) => LINKED.to_vec(),
222 (false, _) => Vec::new(),
223 };
224 let ours = |link: &Path| std::fs::read_link(link).map(|t| t.starts_with(&self.dir)).unwrap_or(false);
225 let relink = |link: &Path, target: &Path| -> Result<(), String> {
226 let tmp = link.with_file_name(format!(".{}.tmp", link.file_name().and_then(|n| n.to_str()).unwrap_or("link")));
227 let _ = std::fs::remove_file(&tmp);
228 std::os::unix::fs::symlink(target, &tmp).map_err(|e| format!("{}: {e}", tmp.display()))?;
229 std::fs::rename(&tmp, link).map_err(|e| format!("{}: {e}", link.display()))
230 };
231 for name in LINKED {
232 let link = prefix.join(name);
233 let target = self.dir.join("current").join("bin").join(name);
234 match link.symlink_metadata() {
235 Ok(m) if m.file_type().is_symlink() && ours(&link) => continue,
236 Ok(m) if m.file_type().is_symlink() || replace.contains(name) => relink(&link, &target)?,
237 Ok(_) if migrating => continue,
238 Ok(_) => {
239 notes.push(format!("{} is not a link into {}; left as it is", link.display(), self.dir.display()));
240 continue;
241 }
242 Err(_) => relink(&link, &target)?,
243 }
244 }
245 if migrating {
246 let libexec = prefix.join("..").join("libexec").join("superfluid");
247 if in_use {
248 let note: String = holding.iter().map(|(p, at)| format!("{p} {at}\n")).collect();
249 std::fs::write(&pending, format!("{}\n{note}", prefix.display())).map_err(|e| format!("{}: {e}", pending.display()))?;
250 notes.push(format!(
251 "a server still runs from the copy the install script placed in {}: its superfluid-workerd, superfluid-noded and workers stay until it stops; the next update, switch or install finishes the move",
252 prefix.display()
253 ));
254 } else {
255 if let Ok(rd) = std::fs::read_dir(&libexec) {
256 for e in rd.flatten() {
257 let name = e.file_name().to_string_lossy().to_string();
258 if name.starts_with("superfluid-worker-") || name.starts_with("libsuperfluid_tokenizer_") {
259 let _ = std::fs::remove_file(e.path());
260 }
261 }
262 let _ = std::fs::remove_dir(&libexec);
263 }
264 let _ = std::fs::remove_file(&pending);
265 notes.push(format!("the install script's copy in {} now links into {}; its workers were removed", prefix.display(), self.dir.display()));
266 }
267 } else if pending.exists() && legacy.is_empty() {
268 let _ = std::fs::remove_file(&pending);
269 }
270 let here = self.on_disk();
271 for v in &here {
272 let link = prefix.join(format!("superfluid-{v}"));
273 if !ours(&link) {
274 relink(&link, &self.binary(v))?;
275 }
276 }
277 if let Ok(rd) = std::fs::read_dir(prefix) {
278 for e in rd.flatten() {
279 let name = e.file_name().to_string_lossy().to_string();
280 if let Some(v) = name.strip_prefix("superfluid-") {
281 if v.chars().next().is_some_and(|c| c.is_ascii_digit()) && !here.iter().any(|h| h == v) && ours(&e.path()) {
282 let _ = std::fs::remove_file(e.path());
283 }
284 }
285 }
286 }
287 Ok(notes)
288 }
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
293pub enum Installed {
294 Managed { versions: PathBuf, version: String },
296 InPlace { prefix: PathBuf },
298 Other(PathBuf),
300}
301
302pub fn installed_as(exe: &Path) -> Installed {
303 let exe = std::fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf());
304 let parts: Vec<&std::ffi::OsStr> = exe.iter().collect();
305 let n = parts.len();
306 if n >= 4 && parts[n - 2] == "bin" && parts[n - 4] == "versions" {
307 let versions = exe.ancestors().nth(3).map(Path::to_path_buf).unwrap_or_default();
308 return Installed::Managed { versions, version: parts[n - 3].to_string_lossy().to_string() };
309 }
310 if let Some(prefix) = exe.parent().filter(|b| b.file_name().is_some_and(|n| n == "bin")).and_then(Path::parent) {
311 if prefix.join(INSTALLED_BY).is_file() || prefix.join(BASERT_STAMP).is_file() {
313 return Installed::Other(exe);
314 }
315 if prefix.join("libexec").join("superfluid").is_dir() {
316 return Installed::InPlace { prefix: prefix.to_path_buf() };
317 }
318 }
319 Installed::Other(exe)
320}
321
322fn package_manager(exe: &Path) -> Option<(String, String)> {
325 let line = std::fs::read_to_string(exe.parent()?.parent()?.join(INSTALLED_BY)).ok()?;
326 let (manager, command) = line.trim().split_once(": ")?;
327 Some((manager.trim().to_string(), command.trim().to_string()))
328}
329
330fn update_command(exe: &Path) -> String {
332 let exe = std::fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf());
333 match installed_as(&exe) {
334 Installed::Other(p) => delegate(&p).map(|d| d.command().to_string()),
335 _ => None,
336 }
337 .unwrap_or_else(|| "superfluid self update".to_string())
338}
339
340fn not_ours(exe: &Path) -> String {
341 let p = exe.to_string_lossy();
342 if let Some(d) = delegate(exe) {
343 return format!("{p} was installed by {}; `{}` updates it", d.manager(), d.command());
344 }
345 let how = if p.contains("/target/") { "it is a source build: build it again" } else { "update it the way it was installed" };
346 format!("{p} was not installed by the install script or by `superfluid self update`; {how}")
347}
348
349#[derive(Debug, Clone, PartialEq, Eq)]
353pub enum Delegate {
354 Run { manager: String, command: String, argv: Vec<String>, path_first: Option<PathBuf> },
357 Name { manager: String, command: String, why: String },
358}
359
360impl Delegate {
361 fn manager(&self) -> &str {
362 match self {
363 Delegate::Run { manager, .. } | Delegate::Name { manager, .. } => manager,
364 }
365 }
366
367 fn command(&self) -> &str {
368 match self {
369 Delegate::Run { command, .. } | Delegate::Name { command, .. } => command,
370 }
371 }
372}
373
374fn delegate(exe: &Path) -> Option<Delegate> {
378 let p = exe.to_string_lossy().into_owned();
379 let bin = exe.parent()?;
380 let root = bin.parent()?;
381 if p.contains("/pipx/venvs/") {
383 return Some(on_path("pipx", &["upgrade", "superfluid-cli"]));
384 }
385 if p.contains("/uv/tools/") {
386 return Some(on_path("uv", &["tool", "upgrade", "superfluid-cli"]));
387 }
388 let named = package_manager(exe);
389 Some(match named.as_ref().map(|(m, _)| m.to_ascii_lowercase()).as_deref() {
390 Some("basert") => basert(root),
391 Some("homebrew" | "brew") => homebrew(&p),
392 Some("npm") => npm(&p),
393 Some("pip") => pip(&p),
394 Some("cargo") => cargo(bin),
395 Some(_) => {
396 let (manager, command) = named?;
397 Delegate::Name { manager, command, why: "superfluid runs only Homebrew, npm, pipx, uv, cargo and basert itself".into() }
398 }
399 None if root.join(BASERT_STAMP).is_file() => basert(root),
400 None if p.contains("/Cellar/") => homebrew(&p),
401 None if p.contains("/node_modules/") => npm(&p),
402 None if p.contains("/site-packages/") => pip(&p),
403 None if p.contains("/.cargo/bin/") => cargo(bin),
404 None => return None,
405 })
406}
407
408fn writable(path: &Path) -> bool {
410 use std::os::unix::ffi::OsStrExt;
411 let Ok(c) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { return false };
412 unsafe { libc::access(c.as_ptr(), libc::W_OK) == 0 }
414}
415
416fn executable(path: &Path) -> bool {
417 use std::os::unix::fs::PermissionsExt;
418 std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
419}
420
421fn on_path(tool: &str, args: &[&str]) -> Delegate {
423 let command = std::iter::once(tool).chain(args.iter().copied()).collect::<Vec<_>>().join(" ");
424 let found = std::env::var_os("PATH").and_then(|path| std::env::split_paths(&path).map(|d| d.join(tool)).find(|p| executable(p)));
425 match found {
426 Some(bin) => Delegate::Run {
427 manager: tool.to_string(),
428 command,
429 argv: std::iter::once(bin.display().to_string()).chain(args.iter().map(|a| a.to_string())).collect(),
430 path_first: None,
431 },
432 None => Delegate::Name { manager: tool.to_string(), why: format!("no {tool} on PATH"), command },
433 }
434}
435
436fn basert(root: &Path) -> Delegate {
439 let beside = root.parent().map(|d| d.join("basert")).filter(|b| executable(b));
440 match beside {
441 Some(b) => Delegate::Run {
442 manager: "basert".into(),
443 command: "basert superfluid install".into(),
444 argv: vec![b.display().to_string(), "superfluid".into(), "install".into()],
445 path_first: None,
446 },
447 None => match on_path("basert", &["superfluid", "install"]) {
448 Delegate::Name { command, why, .. } => Delegate::Name { manager: "basert".into(), command, why },
449 run => run,
450 },
451 }
452}
453
454fn homebrew(p: &str) -> Delegate {
457 let manager = "Homebrew".to_string();
458 let Some(i) = p.find("/Cellar/") else {
459 let why = "the Homebrew it came from cannot be told from where it is".to_string();
460 return Delegate::Name { manager, command: "brew upgrade superfluid".into(), why };
461 };
462 let prefix = Path::new(&p[..i]);
463 let formula = p[i + "/Cellar/".len()..].split('/').next().unwrap_or("superfluid");
464 let command = format!("brew upgrade {formula}");
465 let brew = prefix.join("bin").join("brew");
466 let why = if !executable(&brew) {
467 format!("there is no {}", brew.display())
468 } else if prefix.join("var/homebrew/pinned").join(formula).exists() {
469 format!("it is pinned in Homebrew: `brew unpin {formula}` first")
470 } else if !writable(&prefix.join("Cellar")) {
471 format!("the Homebrew in {} is another user's", prefix.display())
472 } else {
473 let argv = vec![brew.display().to_string(), "upgrade".into(), formula.into()];
474 return Delegate::Run { manager, command, argv, path_first: None };
475 };
476 Delegate::Name { manager, command, why }
477}
478
479fn npm(p: &str) -> Delegate {
483 let manager = "npm".to_string();
484 let command = "npm install --global @basecompute/superfluid@latest".to_string();
485 let Some(i) = p.find("/lib/node_modules/") else {
486 let why = "it is a project's dependency (or pnpm's, yarn's or bun's): update it in that project".to_string();
487 return Delegate::Name { manager, command: "npm install @basecompute/superfluid@latest".into(), why };
488 };
489 let prefix = Path::new(&p[..i]);
490 let bin = prefix.join("bin");
491 let npm = bin.join("npm");
492 let why = if !executable(&npm) {
493 format!("there is no {}", npm.display())
494 } else if !writable(&prefix.join("lib").join("node_modules")) {
495 format!("npm's global folder in {} needs root to write: run it yourself", prefix.display())
496 } else {
497 let argv = vec![
498 npm.display().to_string(),
499 "install".into(),
500 "--global".into(),
501 "--prefix".into(),
502 prefix.display().to_string(),
503 "@basecompute/superfluid@latest".into(),
504 ];
505 return Delegate::Run { manager, command, argv, path_first: Some(bin) };
506 };
507 Delegate::Name { manager, command, why }
508}
509
510fn pip(p: &str) -> Delegate {
513 let python = p.find("/lib/python").map(|i| Path::new(&p[..i]).join("bin").join("python")).filter(|py| executable(py));
515 let command = match python {
516 Some(py) => format!("{} -m pip install --upgrade superfluid-cli", py.display()),
517 None => "pip install --upgrade superfluid-cli".to_string(),
518 };
519 let why = "its environment may be a project's, pinned by a lock file, or the system's: run it in that environment yourself";
520 Delegate::Name { manager: "pip".into(), command, why: why.into() }
521}
522
523fn cargo(bin: &Path) -> Delegate {
525 let cargo = bin.join("cargo");
526 if !executable(&cargo) {
527 return on_path("cargo", &["install", "superfluid"]);
528 }
529 let argv = vec![cargo.display().to_string(), "install".into(), "superfluid".into()];
530 Delegate::Run { manager: "cargo".into(), command: "cargo install superfluid".into(), argv, path_first: Some(bin.to_path_buf()) }
531}
532
533fn confirm(question: &str) -> Result<bool, String> {
535 eprint!("{question}");
536 let _ = std::io::stderr().flush();
537 let mut answer = String::new();
538 std::io::stdin().lock().read_line(&mut answer).map_err(|e| format!("reading the answer: {e}"))?;
539 Ok(matches!(answer.trim().to_ascii_lowercase().as_str(), "" | "y" | "yes"))
540}
541
542fn update_by_manager(exe: &Path, opts: &Options) -> Result<Report, String> {
547 let mut report = Report::default();
548 let Some(plan) = delegate(exe) else { return Err(not_ours(exe)) };
549 let p = exe.display();
550 let (manager, command, argv, path_first) = match (plan, &opts.version) {
551 (Delegate::Name { manager, command, why }, _) => return Err(format!("{p} was installed by {manager}, which updates it: run `{command}` ({why})")),
552 (Delegate::Run { manager, command, .. }, Some(_)) if manager == "basert" => {
553 return Err(format!("{p} is basert's, which picks the release that serves its engine: `{command}` updates it"))
554 }
555 (Delegate::Run { manager, command, .. }, Some(_)) => {
556 return Err(format!("{p} was installed by {manager}, which chooses its version: `{command}` updates it, and a version is pinned there"))
557 }
558 (Delegate::Run { manager, command, argv, path_first }, None) => (manager, command, argv, path_first),
559 };
560 let shown = argv.join(" ");
561 if opts.dry_run {
562 report.say(format!("{p} was installed by {manager}: `self update` would run {shown}"));
563 return Ok(report);
564 }
565 let root = exe.parent().and_then(Path::parent).unwrap_or(Path::new("/"));
568 let watched = if manager == "cargo" { exe.parent().unwrap_or(root) } else { root };
569 let watched = std::fs::canonicalize(watched).unwrap_or_else(|_| watched.to_path_buf());
570 let idle = || -> Result<(), String> {
571 let busy: Vec<String> = other_processes()
572 .into_iter()
573 .filter(|(_, e)| e.starts_with(&watched) && e.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("superfluid")))
574 .map(|(pid, _)| pid.to_string())
575 .collect();
576 if busy.is_empty() {
577 return Ok(());
578 }
579 Err(format!("a server runs from {} (pid {}), which `{command}` replaces: stop it, then run this again", watched.display(), busy.join(", ")))
580 };
581 idle()?;
582 if !opts.yes {
583 if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) {
584 return Err(format!("{p} was installed by {manager}: run `{command}`, or `superfluid self update --yes` to let superfluid run it"));
585 }
586 if !confirm(&format!("superfluid: {p} was installed by {manager}. Run `{shown}`? [Y/n] "))? {
587 return Err(format!("nothing was run; `{command}` updates it"));
588 }
589 idle()?;
591 }
592 if manager == "cargo" {
593 eprintln!("superfluid: cargo builds superfluid from source: this takes a few minutes");
594 }
595 eprintln!("superfluid: running {shown}");
596 let mut run = Command::new(&argv[0]);
597 run.args(&argv[1..]);
598 if let Some(first) = path_first {
599 let rest = std::env::var_os("PATH").unwrap_or_default();
600 let path = std::env::join_paths(std::iter::once(first).chain(std::env::split_paths(&rest))).map_err(|e| e.to_string())?;
601 run.env("PATH", path);
602 }
603 if manager == "basert" {
604 report.hand_off = Some(run);
605 return Ok(report);
606 }
607 let status = crate::exec::when_not_busy(|| run.status()).map_err(|e| format!("running {}: {e}", argv[0]))?;
608 if !status.success() {
609 return Err(format!("`{shown}` failed ({status})"));
610 }
611 report.changed = true;
612 report.say(format!("{manager} updated it"));
613 Ok(report)
614}
615
616
617#[derive(Debug, Clone, PartialEq, Eq)]
618pub struct Release {
619 pub version: String,
620 pub published: String,
622 pub tarball: Asset,
623 pub sha256_url: String,
624 pub abi: Option<u32>,
626 pub compat_url: Option<String>,
627}
628
629pub fn releases(listing: &Value, target: &str) -> Vec<Release> {
631 let tar = format!("superfluid-{target}.tar.gz");
632 let mut out: Vec<Release> = listing
633 .as_array()
634 .map(|a| a.iter().filter_map(|r| release_of(r, &tar, target)).collect())
635 .unwrap_or_default();
636 out.sort_by_key(|r| std::cmp::Reverse(release_order(&r.version)));
637 out
638}
639
640fn release_of(r: &Value, tar: &str, target: &str) -> Option<Release> {
641 if r["draft"].as_bool().unwrap_or(false) || r["prerelease"].as_bool().unwrap_or(false) {
642 return None;
643 }
644 let version = r["tag_name"].as_str()?.trim_start_matches('v').to_string();
645 if !is_version(&version) {
646 return None;
647 }
648 let assets = r["assets"].as_array()?;
649 let url = |name: &str| assets.iter().find(|a| a["name"] == name).and_then(|a| a["browser_download_url"].as_str()).map(str::to_string);
650 let size = assets.iter().find(|a| a["name"] == tar).and_then(|a| a["size"].as_u64());
651 let abi = assets.iter().find_map(|a| a["name"].as_str()?.strip_prefix("superfluid-basert-abi-")?.strip_suffix(".txt")?.parse().ok());
652 Some(Release {
653 published: r["published_at"].as_str().unwrap_or_default().chars().take(10).collect(),
654 tarball: Asset { name: tar.to_string(), url: url(tar)?, sha256: None, size },
655 sha256_url: url(&format!("{tar}.sha256"))?,
656 abi,
657 compat_url: url(&format!("superfluid-{target}.compat.json")),
658 version,
659 })
660}
661
662#[derive(Debug, Clone, PartialEq, Eq)]
664pub enum Engine {
665 None,
667 Abi(u32),
668 Unnumbered(String),
670}
671
672pub fn engine_here(catalog: &Catalog) -> Engine {
673 if std::env::var_os("BASERT_LIB").is_none() && !catalog.package_dir(RuntimeId::new("basert")).is_dir() {
674 return Engine::None;
675 }
676 match catalog.inspect(RuntimeId::new("basert")).usable {
677 Ok(rt) => match (rt.abi, rt.version.as_deref()) {
678 (Some(n), _) => Engine::Abi(n as u32),
679 (None, Some(v)) => Engine::Unnumbered(v.split(|c: char| !c.is_ascii_digit() && c != '.').next().unwrap_or("").split('.').take(2).collect::<Vec<_>>().join(".")),
680 (None, None) => Engine::None,
681 },
682 Err(_) => Engine::None,
683 }
684}
685
686impl Engine {
687 pub fn describe(&self) -> String {
688 match self {
689 Engine::None => "no baseRT engine here".into(),
690 Engine::Abi(n) => format!("the baseRT engine here states ABI {n}"),
691 Engine::Unnumbered(minor) => format!("the baseRT engine here is {minor}.x and states no ABI"),
692 }
693 }
694}
695
696pub fn fits(release: &Release, engine: &Engine, compat: &dyn Fn(&str) -> Option<Value>) -> bool {
698 match engine {
699 Engine::None => true,
700 Engine::Abi(n) => match release.abi {
701 Some(have) => have == *n,
702 None => release.compat_url.as_deref().and_then(compat).and_then(|c| c["runtimes"]["basert"]["abi"].as_u64()) == Some(u64::from(*n)),
703 },
704 Engine::Unnumbered(minor) => release
705 .compat_url
706 .as_deref()
707 .and_then(compat)
708 .and_then(|c| c["runtimes"]["basert"]["unnumbered_minor"].as_str().map(str::to_string))
709 .is_some_and(|m| m == *minor),
710 }
711}
712
713pub fn newest<'a>(releases: &'a [Release], engine: &Engine, hold_off: Option<i64>, compat: &dyn Fn(&str) -> Option<Value>) -> Option<&'a Release> {
715 releases.iter().find(|r| hold_off.is_none_or(|days| age_days(&r.published).is_some_and(|age| age >= days)) && fits(r, engine, compat))
716}
717
718pub fn listing_source() -> String {
720 std::env::var(LISTING_ENV).ok().filter(|s| !s.is_empty()).unwrap_or_else(|| LISTING.to_string())
721}
722
723fn get_text(url: &str, max_time: u32) -> Result<String, String> {
725 if let Some(path) = url.strip_prefix("file://").or_else(|| (!url.contains("://")).then_some(url)) {
726 return std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"));
727 }
728 if !url.starts_with("https://") {
729 return Err(format!("{url}: only https:// URLs are fetched"));
730 }
731 let out = Command::new("curl")
732 .args(["-fsSL", "--proto", "=https", "--tlsv1.2", "--max-time", &max_time.to_string(), "-H", "Accept: application/vnd.github+json"])
733 .arg(url)
734 .output()
735 .map_err(|e| format!("curl: {e}"))?;
736 if !out.status.success() {
737 return Err(format!("fetching {url} failed: {}", String::from_utf8_lossy(&out.stderr).trim()));
738 }
739 Ok(String::from_utf8_lossy(&out.stdout).to_string())
740}
741
742fn get_json(url: &str, max_time: u32) -> Result<Value, String> {
743 serde_json::from_str(&get_text(url, max_time)?).map_err(|e| format!("{url}: {e}"))
744}
745
746pub fn fetch_releases(source: &str, target: &str, max_time: u32) -> Result<Vec<Release>, String> {
747 let listing = get_json(source, max_time)?;
748 let found = releases(&listing, target);
749 if found.is_empty() {
750 return Err(format!("{source} lists no release with a superfluid-{target}.tar.gz"));
751 }
752 Ok(found)
753}
754
755fn compat_fetcher(max_time: u32) -> impl Fn(&str) -> Option<Value> {
756 move |url: &str| get_json(url, max_time).ok()
757}
758
759
760fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
761 let y = if m <= 2 { y - 1 } else { y };
762 let era = if y >= 0 { y } else { y - 399 } / 400;
763 let yoe = y - era * 400;
764 let mp = (m + 9) % 12;
765 let doy = (153 * mp + 2) / 5 + d - 1;
766 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
767 era * 146097 + doe - 719468
768}
769
770fn age_days(published: &str) -> Option<i64> {
771 let mut parts = published.split('-').map(|p| p.parse::<i64>().ok());
772 let (y, m, d) = (parts.next()??, parts.next()??, parts.next()??);
773 let today = (SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs() / 86_400) as i64;
774 Some(today - days_from_civil(y, m, d))
775}
776
777fn now_secs() -> u64 {
778 SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
779}
780
781
782#[cfg(target_os = "linux")]
784fn other_processes() -> Vec<(i32, PathBuf)> {
785 let me = std::process::id() as i32;
786 std::fs::read_dir("/proc")
787 .map(|rd| {
788 rd.flatten()
789 .filter_map(|e| e.file_name().to_str()?.parse::<i32>().ok().filter(|&p| p != me).map(|p| (p, e.path())))
790 .filter_map(|(p, dir)| Some((p, std::fs::read_link(dir.join("exe")).ok()?)))
791 .collect()
792 })
793 .unwrap_or_default()
794}
795
796#[cfg(target_os = "linux")]
798fn started_at(pid: i32) -> Option<u64> {
799 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
800 stat.rsplit_once(')')?.1.split_whitespace().nth(19)?.parse().ok()
802}
803
804#[cfg(target_os = "macos")]
805fn other_processes() -> Vec<(i32, PathBuf)> {
806 let me = std::process::id() as libc::c_int;
807 let n = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
809 if n <= 0 {
810 return Vec::new();
811 }
812 let mut pids = vec![0 as libc::c_int; n as usize * 2 + 16];
813 let bytes = (pids.len() * std::mem::size_of::<libc::c_int>()) as libc::c_int;
814 let got = unsafe { libc::proc_listallpids(pids.as_mut_ptr() as *mut libc::c_void, bytes) };
816 pids.truncate(got.max(0) as usize);
817 pids.into_iter()
818 .filter(|&p| p > 0 && p != me)
819 .filter_map(|p| {
820 let mut buf = vec![0u8; 4096];
821 let len = unsafe { libc::proc_pidpath(p, buf.as_mut_ptr() as *mut libc::c_void, buf.len() as u32) };
823 (len > 0).then(|| (p, PathBuf::from(String::from_utf8_lossy(&buf[..len as usize]).to_string())))
824 })
825 .collect()
826}
827
828#[cfg(target_os = "macos")]
830fn started_at(pid: i32) -> Option<u64> {
831 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
833 let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
834 let got = unsafe { libc::proc_pidinfo(pid, libc::PROC_PIDTBSDINFO, 0, &mut info as *mut _ as *mut libc::c_void, size) };
836 (got == size).then(|| info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec)
837}
838
839#[cfg(not(any(target_os = "linux", target_os = "macos")))]
840fn other_processes() -> Vec<(i32, PathBuf)> {
841 Vec::new()
842}
843
844#[cfg(not(any(target_os = "linux", target_os = "macos")))]
845fn started_at(_pid: i32) -> Option<u64> {
846 None
847}
848
849pub fn running_from(dir: &Path) -> bool {
851 !processes_from(dir).is_empty()
852}
853
854fn processes_from(dir: &Path) -> Vec<(i32, u64)> {
856 let Ok(dir) = std::fs::canonicalize(dir) else { return Vec::new() };
857 other_processes().into_iter().filter(|(_, exe)| exe.starts_with(&dir)).map(|(p, _)| (p, started_at(p).unwrap_or(0))).collect()
858}
859
860
861#[derive(Debug, Default, Clone)]
862pub struct Options {
863 pub version: Option<String>,
864 pub dry_run: bool,
865 pub yes: bool,
867 pub source: Option<String>,
869}
870
871#[derive(Debug, Default)]
873pub struct Report {
874 pub lines: Vec<String>,
875 pub changed: bool,
876 pub hand_off: Option<Command>,
878}
879
880impl Report {
881 fn say(&mut self, line: impl Into<String>) {
882 self.lines.push(line.into());
883 }
884}
885
886fn output_within(cmd: &mut Command, limit: Duration) -> Result<Output, String> {
888 let what = cmd.get_program().to_string_lossy().to_string();
889 match crate::exec::output_within(cmd, limit) {
890 Ok(Some(out)) => Ok(out),
891 Ok(None) => Err(format!("{what} did not answer within {}s", limit.as_secs())),
892 Err(e) => Err(format!("{what}: {e}")),
893 }
894}
895
896fn version_of(binary: &Path) -> Result<String, String> {
897 let out = output_within(Command::new(binary).arg("--version"), PROBE_TIMEOUT)?;
898 let text = String::from_utf8_lossy(&out.stdout);
899 text.split_whitespace().nth(1).map(str::to_string).filter(|v| out.status.success() && is_version(v)).ok_or_else(|| {
900 format!("{} did not say its version ({}): {}", binary.display(), out.status, String::from_utf8_lossy(&out.stderr).trim())
901 })
902}
903
904fn probe(versions: &Versions, v: &str, home: &Path, engine: &Engine) -> Result<(), String> {
906 probe_binary(&versions.binary(v), v, home, engine)
907}
908
909fn probe_binary(binary: &Path, v: &str, home: &Path, engine: &Engine) -> Result<(), String> {
911 if !binary.with_file_name("superfluid-workerd").is_file() {
913 return Err(format!("{} has no superfluid-workerd beside it", binary.display()));
914 }
915 let said = version_of(binary)?;
916 if said != v {
917 return Err(format!("{} says it is {said}, not {v}", binary.display()));
918 }
919 let out = output_within(Command::new(binary).args(["runtimes", "--json"]).env("SUPERFLUID_HOME", home).env(NO_CHECK_ENV, "1"), PROBE_TIMEOUT)?;
920 if !out.status.success() {
921 return Err(format!("{} runtimes --json failed ({}): {}", binary.display(), out.status, String::from_utf8_lossy(&out.stderr).trim()));
922 }
923 if *engine != Engine::None {
924 let rows: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("{} runtimes --json: {e}", binary.display()))?;
925 let basert = rows.as_array().and_then(|a| a.iter().find(|r| r["id"] == "basert"));
926 match basert.map(|r| &r["ready"]) {
927 Some(Value::Bool(true)) => {}
928 Some(Value::String(why)) => return Err(format!("{v} cannot drive the engine here: {why}")),
929 _ => return Err(format!("{v} reports no basert runtime, which this machine serves with")),
930 }
931 }
932 Ok(())
933}
934
935fn move_tree(from: &Path, to: &Path) -> Result<(), String> {
936 if std::fs::rename(from, to).is_ok() {
937 return Ok(());
938 }
939 copy_tree(from, to)?;
940 std::fs::remove_dir_all(from).map_err(|e| format!("{}: {e}", from.display()))
941}
942
943fn copy_tree(from: &Path, to: &Path) -> Result<(), String> {
944 std::fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?;
945 for e in std::fs::read_dir(from).map_err(|e| format!("{}: {e}", from.display()))?.flatten() {
946 let (src, dest) = (e.path(), to.join(e.file_name()));
947 let kind = e.file_type().map_err(|e| format!("{}: {e}", src.display()))?;
948 if kind.is_dir() {
949 copy_tree(&src, &dest)?;
950 } else if kind.is_symlink() {
951 let target = std::fs::read_link(&src).map_err(|e| format!("{}: {e}", src.display()))?;
952 std::os::unix::fs::symlink(target, &dest).map_err(|e| format!("{}: {e}", dest.display()))?;
953 } else {
954 std::fs::copy(&src, &dest).map_err(|e| format!("{} -> {}: {e}", src.display(), dest.display()))?;
955 }
956 }
957 Ok(())
958}
959
960fn place(versions: &Versions, release: &Release, report: &mut Report) -> Result<(), String> {
963 let staging = versions.dir.join(".staging").join(&release.version);
964 let _ = std::fs::remove_dir_all(&staging);
965 let placed = place_through(versions, release, &staging, report);
966 let _ = std::fs::remove_dir_all(&staging);
967 placed
968}
969
970fn place_through(versions: &Versions, release: &Release, staging: &Path, report: &mut Report) -> Result<(), String> {
971 let v = &release.version;
972 std::fs::create_dir_all(staging).map_err(|e| format!("{}: {e}", staging.display()))?;
973 let want = get_text(&release.sha256_url, 30)?.split_whitespace().next().unwrap_or_default().to_ascii_lowercase();
974 if want.len() != 64 || !want.bytes().all(|b| b.is_ascii_hexdigit()) {
975 return Err(format!("{}: not a SHA-256", release.sha256_url));
976 }
977 let asset = Asset { sha256: Some(want), ..release.tarball.clone() };
978 report.say(format!("downloading {} ({})", asset.name, asset.size.map(|s| format!("{:.1} MB", s as f64 / 1e6)).unwrap_or_else(|| "size unknown".into())));
979 let (archive, _) = fetch(&asset, staging)?;
980 let unpacked = staging.join("unpacked");
981 unpack(&archive, &unpacked)?;
982 let top = single_top(&unpacked)?;
983 for needed in ["bin/superfluid", "bin/superfluid-workerd"] {
984 if !top.join(needed).is_file() {
985 return Err(format!("{} holds no {needed}", asset.name));
986 }
987 }
988 let dest = versions.version_dir(v);
989 let _ = std::fs::remove_dir_all(&dest);
990 move_tree(&top, &dest).inspect_err(|_| {
992 let _ = std::fs::remove_dir_all(&dest);
993 })
994}
995
996fn keep_in_place(versions: &Versions, prefix: &Path) -> Result<String, String> {
1000 let bin = prefix.join("bin");
1001 let libexec = prefix.join("libexec").join("superfluid");
1002 let old = version_of(&bin.join("superfluid"))?;
1003 let dest = versions.version_dir(&old);
1004 if holds_in_place(&dest, &bin, &libexec) {
1005 return Ok(old);
1006 }
1007 let staging = versions.dir.join(".staging").join(format!("kept-{old}"));
1008 let _ = std::fs::remove_dir_all(&staging);
1009 let copied = (|| {
1010 std::fs::create_dir_all(staging.join("bin")).map_err(|e| format!("{}: {e}", staging.display()))?;
1011 for name in LINKED {
1012 let src = bin.join(name);
1013 if src.is_file() {
1014 std::fs::copy(&src, staging.join("bin").join(name)).map_err(|e| format!("{}: {e}", src.display()))?;
1015 }
1016 }
1017 if libexec.is_dir() {
1018 copy_tree(&libexec, &staging.join("libexec").join("superfluid"))?;
1019 }
1020 let _ = std::fs::remove_dir_all(&dest);
1021 std::fs::rename(&staging, &dest).map_err(|e| format!("{} -> {}: {e}", staging.display(), dest.display()))
1022 })();
1023 if copied.is_err() {
1024 let _ = std::fs::remove_dir_all(&staging);
1025 }
1026 copied.map(|()| old)
1027}
1028
1029fn holds_in_place(dest: &Path, bin: &Path, libexec: &Path) -> bool {
1031 let commands = LINKED.iter().filter(|name| bin.join(name).is_file()).all(|name| dest.join("bin").join(name).is_file());
1032 let workers = std::fs::read_dir(libexec)
1033 .map(|rd| rd.flatten().all(|e| dest.join("libexec").join("superfluid").join(e.file_name()).exists()))
1034 .unwrap_or(true);
1035 dest.join("bin").join("superfluid").is_file() && commands && workers
1036}
1037
1038fn migrate(versions: &Versions, prefix: &Path, report: &mut Report) -> Result<(), String> {
1040 for note in versions.link(&prefix.join("bin"), true)? {
1041 report.say(note);
1042 }
1043 report.say(format!("the copy the install script placed in {}/bin is kept under {} as the version to go back to", prefix.display(), versions.dir.display()));
1044 Ok(())
1045}
1046
1047fn replace_tree(from: &Path, dest: &Path, aside: &Path, mover: impl Fn(&Path, &Path) -> Result<(), String>) -> Result<(), String> {
1050 let _ = std::fs::remove_dir_all(aside);
1051 let had = dest.exists();
1052 if had {
1053 std::fs::rename(dest, aside).map_err(|e| format!("{}: {e}", dest.display()))?;
1054 }
1055 match mover(from, dest) {
1056 Ok(()) => {
1057 let _ = std::fs::remove_dir_all(aside);
1058 Ok(())
1059 }
1060 Err(e) => {
1061 let _ = std::fs::remove_dir_all(dest);
1062 if had {
1063 if let Err(back) = std::fs::rename(aside, dest) {
1064 return Err(format!("{e}; and {} could not be put back from {}: {back}", dest.display(), aside.display()));
1065 }
1066 }
1067 Err(e)
1068 }
1069 }
1070}
1071
1072fn prune_older(versions: &Versions, report: &mut Report) {
1075 match versions.prune() {
1076 Ok(gone) if !gone.is_empty() => report.say(format!("removed {}", gone.join(", "))),
1077 Ok(_) => {}
1078 Err(e) => report.say(format!("an older version was not removed ({e}); the next update tries again")),
1079 }
1080}
1081
1082pub fn update(home: &Path, catalog: &Catalog, exe: &Path, opts: &Options) -> Result<Report, String> {
1084 let mut report = Report::default();
1085 let installed = installed_as(exe);
1086 let (versions, migrating) = match &installed {
1087 Installed::Managed { versions, .. } => (Versions { dir: versions.clone() }, None),
1088 Installed::InPlace { prefix } => (Versions::of(home), Some(prefix.clone())),
1089 Installed::Other(p) => return update_by_manager(p, opts),
1090 };
1091 let target = target().ok_or_else(|| format!("no release is published for {} {}", std::env::consts::OS, std::env::consts::ARCH))?;
1092 let _one_at_a_time = match opts.dry_run {
1094 true => None,
1095 false => {
1096 std::fs::create_dir_all(&versions.dir).map_err(|e| format!("{}: {e}", versions.dir.display()))?;
1097 Some(crate::pulls::Lock::take(&versions.dir, "self", "update of superfluid")?)
1098 }
1099 };
1100 let engine = engine_here(catalog);
1101 let compat = compat_fetcher(15);
1102 let found = fetch_releases(opts.source.as_deref().unwrap_or(&listing_source()), target, 30)?;
1103 let chosen = match &opts.version {
1104 Some(want) => {
1105 let want = want.trim_start_matches('v');
1106 let r = found.iter().find(|r| r.version == want).ok_or_else(|| {
1107 format!("no release {want} for {target} (the newest is {})", found.first().map(|r| r.version.as_str()).unwrap_or("none"))
1108 })?;
1109 if !fits(r, &engine, &compat) {
1110 return Err(format!("{want} does not declare support for the engine here ({}); `superfluid runtimes --json` shows what this build requires", engine.describe()));
1111 }
1112 r
1113 }
1114 None => newest(&found, &engine, None, &compat).ok_or_else(|| {
1115 format!("no release fits: {}, and the newest release for {target} is {}", engine.describe(), found.first().map(|r| r.version.as_str()).unwrap_or("none"))
1116 })?,
1117 };
1118 let v = chosen.version.clone();
1119 let current = versions.current();
1120 if migrating.is_none() && current.as_deref() == Some(v.as_str()) {
1121 report.say(format!("superfluid {v} is current, and the newest release that fits ({})", engine.describe()));
1122 return Ok(report);
1123 }
1124 let here = current.clone().unwrap_or_else(|| running().to_string());
1126 if opts.version.is_none() && migrating.is_none() && release_order(&v) < release_order(&here) {
1127 report.say(format!("superfluid {here} is current; the newest release that fits is {v} ({}), and `--version {v}` goes to it", engine.describe()));
1128 return Ok(report);
1129 }
1130 let on_disk = versions.binary(&v).is_file();
1131 report.say(format!(
1132 "{} -> {v} (published {}; {}){}",
1133 current.as_deref().unwrap_or(running()),
1134 chosen.published,
1135 engine.describe(),
1136 if on_disk { ", already installed here" } else { "" }
1137 ));
1138 if opts.dry_run {
1139 report.say("dry run: nothing fetched or changed");
1140 return Ok(report);
1141 }
1142 let kept = match &migrating {
1144 Some(prefix) => Some(keep_in_place(&versions, prefix)?),
1145 None => None,
1146 };
1147 let on_disk = on_disk || versions.binary(&v).is_file();
1148 if !on_disk {
1149 place(&versions, chosen, &mut report)?;
1150 }
1151 if let Err(e) = probe(&versions, &v, home, &engine) {
1152 if !on_disk {
1153 let _ = std::fs::remove_dir_all(versions.version_dir(&v));
1154 }
1155 return Err(format!("{v} was not made current: {e}"));
1156 }
1157 versions.set_current(&v)?;
1158 if let Some(old) = kept.filter(|old| *old != v) {
1159 std::fs::write(versions.dir.join("previous"), format!("{old}\n")).map_err(|e| format!("{}: {e}", versions.dir.display()))?;
1160 }
1161 report.changed = true;
1162 report.say(format!("superfluid {v} is current ({})", versions.dir.join("current").display()));
1163 if let Some(prev) = versions.previous() {
1164 report.say(format!("{prev} stays installed: `superfluid self use {prev}` goes back to it"));
1165 }
1166 prune_older(&versions, &mut report);
1168 if let Some(prefix) = &migrating {
1169 migrate(&versions, prefix, &mut report)?;
1170 } else if let Some(prefix) = versions.prefix() {
1171 for note in versions.link(&prefix, false)? {
1172 report.say(note);
1173 }
1174 }
1175 report.say("a running server keeps the version it started with; starting it again uses the new one");
1176 Ok(report)
1177}
1178
1179pub fn use_version(home: &Path, catalog: &Catalog, exe: &Path, v: &str) -> Result<Report, String> {
1181 let versions = match installed_as(exe) {
1182 Installed::Managed { versions, .. } => Versions { dir: versions },
1183 _ => Versions::of(home),
1184 };
1185 let v = v.trim_start_matches('v');
1186 if !is_version(v) {
1187 return Err(format!("{v:?} is not a version (see `superfluid self list`)"));
1188 }
1189 let mut report = Report::default();
1190 std::fs::create_dir_all(&versions.dir).map_err(|e| format!("{}: {e}", versions.dir.display()))?;
1191 let _one_at_a_time = crate::pulls::Lock::take(&versions.dir, "self", "switch of superfluid")?;
1192 if versions.current().as_deref() == Some(v) {
1193 report.say(format!("superfluid {v} is current already"));
1194 return Ok(report);
1195 }
1196 probe(&versions, v, home, &engine_here(catalog))?;
1197 versions.set_current(v)?;
1198 report.changed = true;
1199 report.say(format!("superfluid {v} is current"));
1200 if let Some(prefix) = versions.prefix() {
1201 for note in versions.link(&prefix, false)? {
1202 report.say(note);
1203 }
1204 }
1205 Ok(report)
1206}
1207
1208pub fn install_from(home: &Path, catalog: &Catalog, from: &Path, prefix: Option<&Path>) -> Result<Report, String> {
1211 let versions = Versions::of(home);
1212 let mut report = Report::default();
1213 std::fs::create_dir_all(&versions.dir).map_err(|e| format!("{}: {e}", versions.dir.display()))?;
1214 let _one_at_a_time = crate::pulls::Lock::take(&versions.dir, "self", "install of superfluid")?;
1215 let v = version_of(&from.join("bin").join("superfluid"))?;
1216 let engine = engine_here(catalog);
1217 let dest = versions.version_dir(&v);
1218 if dest == from {
1219 probe(&versions, &v, home, &engine).map_err(|e| format!("{v} was not made current: {e}"))?;
1220 } else if versions.binary(&v).is_file() && probe(&versions, &v, home, &engine).is_ok() {
1221 report.say(format!("superfluid {v} is installed here already and works; the copy given was not used"));
1223 } else {
1224 probe_binary(&from.join("bin").join("superfluid"), &v, home, &engine).map_err(|e| format!("{v} was not made current: {e}"))?;
1226 replace_tree(from, &dest, &versions.dir.join(format!(".replaced-{v}")), move_tree)?;
1227 }
1228 let kept = match prefix.and_then(|p| match installed_as(&p.join("superfluid")) {
1230 Installed::InPlace { prefix: root } => Some(root),
1231 _ => None,
1232 }) {
1233 Some(root) => Some(keep_in_place(&versions, &root)?),
1234 None => None,
1235 };
1236 versions.set_current(&v)?;
1237 if let Some(old) = kept.filter(|old| *old != v) {
1238 std::fs::write(versions.dir.join("previous"), format!("{old}\n")).map_err(|e| format!("{}: {e}", versions.dir.display()))?;
1239 report.say(format!("the copy that was in the prefix is kept as {old}: `superfluid self use {old}` goes back to it"));
1240 }
1241 report.changed = true;
1242 report.say(format!("superfluid {v} is current ({})", versions.dir.join("current").display()));
1243 prune_older(&versions, &mut report);
1244 if let Some(prefix) = prefix.map(Path::to_path_buf).or_else(|| versions.prefix()) {
1245 for note in versions.link(&prefix, true)? {
1246 report.say(note);
1247 }
1248 report.say(format!("{} links to it", prefix.join("superfluid").display()));
1249 }
1250 Ok(report)
1251}
1252
1253pub fn list(home: &Path, exe: &Path) -> String {
1255 let (versions, note) = match installed_as(exe) {
1256 Installed::Managed { versions, .. } => (Versions { dir: versions }, None),
1257 Installed::InPlace { prefix } => (Versions::of(home), Some(format!("this superfluid is the install script's copy in {}/bin; `superfluid self update` moves it under {}", prefix.display(), home.join("versions").display()))),
1258 Installed::Other(p) => (Versions::of(home), Some(not_ours(&p))),
1259 };
1260 let mut out = String::new();
1261 let here = versions.on_disk();
1262 if here.is_empty() {
1263 out.push_str(&format!("no versions in {}\n", versions.dir.display()));
1264 } else {
1265 out.push_str(&format!("versions in {}:\n", versions.dir.display()));
1266 let (current, previous) = (versions.current(), versions.previous());
1267 for v in &here {
1268 let mut marks = Vec::new();
1269 if Some(v) == current.as_ref() {
1270 marks.push("current".to_string());
1271 }
1272 if Some(v) == previous.as_ref() {
1273 marks.push(format!("previous (`superfluid self use {v}` goes back)"));
1274 }
1275 if versions.version_dir(v).join("keep").exists() {
1276 marks.push("kept (a `keep` file)".into());
1277 }
1278 out.push_str(&format!(" {v} {}\n", marks.join(", ")));
1279 }
1280 }
1281 out.push_str(&format!("running: {}\n", running()));
1282 if let Some(cache) = Check::read(home) {
1283 out.push_str(&format!("{}\n", cache.describe()));
1284 }
1285 if let Some(n) = note {
1286 out.push_str(&format!("{n}\n"));
1287 }
1288 out
1289}
1290
1291
1292#[derive(Debug, Clone, Default, PartialEq, Eq)]
1294pub struct Check {
1295 pub checked_at: u64,
1296 pub newest: Option<String>,
1297 pub published: Option<String>,
1298 pub notified: Option<String>,
1299}
1300
1301impl Check {
1302 fn path(home: &Path) -> PathBuf {
1303 home.join("update-check.json")
1304 }
1305
1306 pub fn read(home: &Path) -> Option<Check> {
1307 let v: Value = serde_json::from_str(&std::fs::read_to_string(Self::path(home)).ok()?).ok()?;
1308 Some(Check {
1309 checked_at: v["checked_at"].as_u64().unwrap_or(0),
1310 newest: v["newest"].as_str().map(str::to_string),
1311 published: v["published"].as_str().map(str::to_string),
1312 notified: v["notified"].as_str().map(str::to_string),
1313 })
1314 }
1315
1316 fn write(&self, home: &Path) -> Result<(), String> {
1317 std::fs::create_dir_all(home).map_err(|e| format!("{}: {e}", home.display()))?;
1318 let v = json!({"checked_at": self.checked_at, "newest": self.newest, "published": self.published, "notified": self.notified});
1319 std::fs::write(Self::path(home), format!("{v:#}\n")).map_err(|e| format!("{}: {e}", Self::path(home).display()))
1320 }
1321
1322 pub fn due(&self) -> bool {
1323 now_secs().saturating_sub(self.checked_at) >= CHECK_EVERY.as_secs()
1324 }
1325
1326 pub fn newer(&self) -> Option<&str> {
1328 self.newest.as_deref().filter(|n| release_order(n) > release_order(running()))
1329 }
1330
1331 pub fn describe(&self) -> String {
1332 let ago = now_secs().saturating_sub(self.checked_at);
1333 let when = if ago < 3600 { format!("{}m ago", ago / 60) } else if ago < 86_400 { format!("{}h ago", ago / 3600) } else { format!("{}d ago", ago / 86_400) };
1334 match self.newer() {
1335 Some(n) => format!("newest release: {n} (published {}; checked {when}): `superfluid self update` installs it", self.published.as_deref().unwrap_or("?")),
1336 None => format!("newest release that fits: {} (checked {when})", self.newest.as_deref().unwrap_or("none known")),
1337 }
1338 }
1339}
1340
1341pub fn check_now(home: &Path, catalog: &Catalog, hold_off: Option<i64>, source: &str) -> Result<Check, String> {
1343 let target = target().ok_or("no release is published for this platform")?;
1344 let found = fetch_releases(source, target, 5)?;
1345 let engine = engine_here(catalog);
1346 let compat = compat_fetcher(5);
1347 let fit = newest(&found, &engine, hold_off, &compat);
1348 let previous = Check::read(home).unwrap_or_default();
1349 let check = Check {
1350 checked_at: now_secs(),
1351 newest: fit.map(|r| r.version.clone()),
1352 published: fit.map(|r| r.published.clone()),
1353 notified: previous.notified,
1354 };
1355 check.write(home)?;
1356 Ok(check)
1357}
1358
1359fn announce(home: &Path, mut check: Check, command: &str) -> Option<String> {
1361 let newer = check.newer()?.to_string();
1362 if check.notified.as_deref() == Some(newer.as_str()) {
1363 return None;
1364 }
1365 check.notified = Some(newer.clone());
1366 let _ = check.write(home);
1367 Some(format!("superfluid {newer} is available (this is {}): `{command}` installs it; {NO_CHECK_ENV}=1 silences this", running()))
1368}
1369
1370pub fn check_in_background(home: PathBuf, offline: bool) {
1372 if offline || std::env::var_os(NO_CHECK_ENV).is_some_and(|v| !v.is_empty() && v != "0") {
1373 return;
1374 }
1375 std::thread::Builder::new()
1376 .name("update-check".into())
1377 .spawn(move || loop {
1378 if let Some(line) = tick(&home, &Catalog::from_env(), &listing_source()) {
1379 eprintln!("superfluid: {line}");
1380 }
1381 std::thread::sleep(Duration::from_secs(60 * 60));
1383 })
1384 .ok();
1385}
1386
1387pub fn tick(home: &Path, catalog: &Catalog, source: &str) -> Option<String> {
1389 let command = update_command(&std::env::current_exe().unwrap_or_default());
1390 match Check::read(home).filter(|c| !c.due()) {
1391 Some(cached) => announce(home, cached, &command),
1392 None => announce(home, check_now(home, catalog, Some(HOLD_OFF_DAYS), source).ok()?, &command),
1393 }
1394}
1395
1396
1397fn usage() -> ! {
1398 eprint!("{HELP}");
1399 std::process::exit(2);
1400}
1401
1402pub fn run(args: &[String]) -> i32 {
1404 let home = crate::runtimes::default_home();
1405 let exe = std::env::current_exe().unwrap_or_default();
1406 let catalog = Catalog::from_env();
1407 let args = crate::cli::split_equals(args, |f| Some(matches!(f, "--version" | "--from" | "--prefix")));
1408 let print = |r: Result<Report, String>| -> i32 {
1409 match r {
1410 Ok(report) => {
1411 for line in report.lines {
1412 println!("superfluid: {line}");
1413 }
1414 0
1415 }
1416 Err(e) => {
1417 eprintln!("superfluid: {e}");
1418 1
1419 }
1420 }
1421 };
1422 match args.first().map(String::as_str) {
1423 Some("update") => {
1424 let mut opts = Options::default();
1425 let mut it = args[1..].iter();
1426 while let Some(a) = it.next() {
1427 match a.as_str() {
1428 "--version" => opts.version = it.next().cloned().or_else(|| usage()),
1429 "--dry-run" => opts.dry_run = true,
1430 "--yes" | "-y" => opts.yes = true,
1431 _ => usage(),
1432 }
1433 }
1434 match update(&home, &catalog, &exe, &opts) {
1435 Ok(Report { hand_off: Some(mut cmd), .. }) => {
1436 use std::os::unix::process::CommandExt;
1437 let _ = std::io::stdout().flush();
1438 let e = cmd.exec();
1440 eprintln!("superfluid: running {}: {e}", cmd.get_program().to_string_lossy());
1441 1
1442 }
1443 r => print(r),
1444 }
1445 }
1446 Some("check") if args.len() == 1 => match check_now(&home, &catalog, Some(HOLD_OFF_DAYS), &listing_source()) {
1447 Ok(check) => {
1448 println!("superfluid: {}", check.describe());
1449 0
1450 }
1451 Err(e) => {
1452 eprintln!("superfluid: {e}");
1453 1
1454 }
1455 },
1456 Some("list") if args.len() == 1 => {
1457 let _ = std::io::stdout().write_all(list(&home, &exe).as_bytes());
1458 0
1459 }
1460 Some("use") if args.len() == 2 => print(use_version(&home, &catalog, &exe, &args[1])),
1461 Some("install") => {
1462 let (mut from, mut prefix) = (None, None);
1463 let mut it = args[1..].iter();
1464 while let Some(a) = it.next() {
1465 match a.as_str() {
1466 "--from" => from = it.next().map(PathBuf::from).or_else(|| usage()),
1467 "--prefix" => prefix = it.next().map(PathBuf::from).or_else(|| usage()),
1468 _ => usage(),
1469 }
1470 }
1471 let Some(from) = from else { usage() };
1472 print(install_from(&home, &catalog, &from, prefix.as_deref()))
1473 }
1474 _ => usage(),
1475 }
1476}
1477
1478#[cfg(test)]
1479mod tests {
1480 use super::*;
1481 use std::os::unix::fs::PermissionsExt;
1482 use std::process::Stdio;
1483 use superfluid_adapter_kit::install::sha256_of;
1484
1485 fn scratch(tag: &str) -> PathBuf {
1486 let d = std::env::temp_dir().join(format!("superfluid-self-{tag}-{}", std::process::id()));
1487 let _ = std::fs::remove_dir_all(&d);
1488 std::fs::create_dir_all(&d).unwrap();
1489 d
1490 }
1491
1492 fn release_tree(dir: &Path, v: &str) -> PathBuf {
1494 let bin = dir.join("bin");
1495 std::fs::create_dir_all(&bin).unwrap();
1496 std::fs::create_dir_all(dir.join("libexec").join("superfluid")).unwrap();
1497 let script = format!(
1498 "#!/bin/sh\ncase \"$1\" in\n --version) echo \"superfluid {v}\";;\n runtimes) echo '[{{\"id\":\"basert\",\"ready\":true}}]';;\n *) exit 2;;\nesac\n"
1499 );
1500 for name in LINKED {
1501 let p = bin.join(name);
1502 std::fs::write(&p, &script).unwrap();
1503 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
1504 }
1505 std::fs::write(dir.join("libexec").join("superfluid").join("superfluid-worker-toy"), "#!/bin/sh\n").unwrap();
1506 dir.to_path_buf()
1507 }
1508
1509 fn listing(dir: &Path, versions: &[(&str, &str, Option<u32>)]) -> PathBuf {
1511 let target = target().unwrap();
1512 let mut rs = Vec::new();
1513 for (v, published, abi) in versions {
1514 let name = format!("superfluid-{target}");
1515 let tree = dir.join(format!("tree-{v}")).join(&name);
1516 release_tree(&tree, v);
1517 let tar = dir.join(format!("{name}-{v}.tar.gz"));
1518 let st = Command::new("tar").args(["-C"]).arg(tree.parent().unwrap()).arg("-czf").arg(&tar).arg(&name).status().unwrap();
1519 assert!(st.success());
1520 let sum = sha256_of(&tar).unwrap();
1521 let sha = dir.join(format!("{name}-{v}.tar.gz.sha256"));
1522 std::fs::write(&sha, format!("{sum} {name}.tar.gz\n")).unwrap();
1523 let compat = dir.join(format!("{name}-{v}.compat.json"));
1524 std::fs::write(&compat, json!({"runtimes": {"basert": {"abi": abi, "unnumbered_minor": abi.map(|_| "0.3")}}}).to_string()).unwrap();
1525 let mut assets = vec![
1526 json!({"name": format!("{name}.tar.gz"), "browser_download_url": tar, "size": std::fs::metadata(&tar).unwrap().len()}),
1527 json!({"name": format!("{name}.tar.gz.sha256"), "browser_download_url": sha}),
1528 json!({"name": format!("{name}.compat.json"), "browser_download_url": compat}),
1529 ];
1530 if let Some(n) = abi {
1531 assets.push(json!({"name": format!("superfluid-basert-abi-{n}.txt"), "browser_download_url": "x"}));
1532 }
1533 rs.push(json!({"tag_name": format!("v{v}"), "draft": false, "prerelease": false, "published_at": format!("{published}T12:00:00Z"), "assets": assets}));
1534 }
1535 rs.push(json!({"tag_name": "v9.9.9", "draft": true, "prerelease": false, "published_at": "2026-01-01T00:00:00Z", "assets": []}));
1536 let p = dir.join("releases.json");
1537 std::fs::write(&p, Value::Array(rs).to_string()).unwrap();
1538 p
1539 }
1540
1541 fn no_compat(_: &str) -> Option<Value> {
1542 None
1543 }
1544
1545 #[test]
1546 fn a_listing_is_read_newest_first_and_a_release_fits_by_the_engine_it_declares() {
1547 let dir = scratch("listing");
1548 let p = listing(&dir, &[("0.1.3", "2026-10-01", Some(1)), ("0.2.0", "2026-10-20", Some(2)), ("0.1.2", "2026-09-01", None)]);
1549 let found = releases(&serde_json::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap(), target().unwrap());
1550 assert_eq!(found.iter().map(|r| r.version.as_str()).collect::<Vec<_>>(), ["0.2.0", "0.1.3", "0.1.2"], "newest first, the draft left out");
1551 assert_eq!((found[0].abi, found[1].abi, found[2].abi), (Some(2), Some(1), None));
1552 assert_eq!(found[1].published, "2026-10-01");
1553 let odd = json!([{"tag_name": "v", "assets": []}, {"tag_name": "v../..", "assets": []}, {"tag_name": "vnext", "assets": []}]);
1554 assert_eq!(odd.as_array().unwrap().iter().filter_map(|r| release_of(r, "x.tar.gz", target().unwrap())).count(), 0, "a tag that is no version names no directory");
1555 assert!(["0.1.3", "0.2.0-rc.1", "1.0.0+build.5"].iter().all(|v| is_version(v)));
1556 let mut order = ["0.2.0-rc.1", "0.1.9", "0.2.0", "0.2.0-rc.10", "0.2.1-alpha", "0.2.0-beta", "0.2.0-rc.2", "0.2.0-rc", "0.10.0+build.5"];
1557 order.sort_by_key(|v| std::cmp::Reverse(release_order(v)));
1558 assert_eq!(order, ["0.10.0+build.5", "0.2.1-alpha", "0.2.0", "0.2.0-rc.10", "0.2.0-rc.2", "0.2.0-rc.1", "0.2.0-rc", "0.2.0-beta", "0.1.9"]);
1559 assert!(["", ".", "..", "../0.1.3", "0.1.3/..", "v0.1.3"].iter().all(|v| !is_version(v)));
1560 let compat = |url: &str| get_json(url, 1).ok();
1561 assert_eq!(newest(&found, &Engine::None, None, &no_compat).map(|r| r.version.as_str()), Some("0.2.0"));
1562 assert_eq!(newest(&found, &Engine::Abi(1), None, &no_compat).map(|r| r.version.as_str()), Some("0.1.3"), "by the marker alone");
1563 assert_eq!(newest(&found, &Engine::Abi(3), None, &compat), None);
1564 assert_eq!(newest(&found, &Engine::Unnumbered("0.3".into()), None, &compat).map(|r| r.version.as_str()), Some("0.2.0"), "compat.json names the line");
1565 assert_eq!(newest(&found, &Engine::Unnumbered("0.2".into()), None, &compat), None);
1566 assert_eq!(newest(&found, &Engine::None, Some(100_000), &no_compat), None);
1567 assert_eq!(age_days("2000-01-01").map(|d| d > 9000), Some(true));
1568 assert_eq!(days_from_civil(1970, 1, 1), 0);
1569 assert_eq!(days_from_civil(2000, 3, 1), 11017);
1570 let _ = std::fs::remove_dir_all(&dir);
1571 }
1572
1573 #[test]
1574 fn where_an_executable_came_from_decides_who_updates_it() {
1575 let dir = scratch("installed");
1576 let managed = dir.join("home").join("versions").join("0.1.3").join("bin").join("superfluid");
1577 std::fs::create_dir_all(managed.parent().unwrap()).unwrap();
1578 std::fs::write(&managed, "").unwrap();
1579 assert_eq!(installed_as(&managed), Installed::Managed { versions: std::fs::canonicalize(dir.join("home").join("versions")).unwrap(), version: "0.1.3".into() });
1580 let in_place = dir.join("local").join("bin").join("superfluid");
1581 std::fs::create_dir_all(in_place.parent().unwrap()).unwrap();
1582 std::fs::create_dir_all(dir.join("local").join("libexec").join("superfluid")).unwrap();
1583 std::fs::write(&in_place, "").unwrap();
1584 assert_eq!(installed_as(&in_place), Installed::InPlace { prefix: std::fs::canonicalize(dir.join("local")).unwrap() });
1585 let other = dir.join("Cellar").join("superfluid").join("0.1.3").join("bin").join("superfluid");
1586 std::fs::create_dir_all(other.parent().unwrap()).unwrap();
1587 std::fs::write(&other, "").unwrap();
1588 assert!(matches!(installed_as(&other), Installed::Other(_)));
1589 assert!(not_ours(&other).contains("brew upgrade superfluid"));
1590 let packaged = release_tree(&dir.join("site-packages").join("superfluid_cli").join("release"), "0.1.3").join("bin").join("superfluid");
1592 assert!(matches!(installed_as(&packaged), Installed::InPlace { .. }), "without the file it looks like the install script's copy");
1593 std::fs::write(dir.join("site-packages").join("superfluid_cli").join("release").join(INSTALLED_BY), "pip: pip install --upgrade superfluid-cli\n").unwrap();
1594 assert!(matches!(installed_as(&packaged), Installed::Other(_)));
1595 let packaged = std::fs::canonicalize(&packaged).unwrap();
1596 assert_eq!(not_ours(&packaged), format!("{} was installed by pip; `pip install --upgrade superfluid-cli` updates it", packaged.display()));
1597 assert_eq!(update_command(&packaged), "pip install --upgrade superfluid-cli");
1598 assert_eq!(update_command(&in_place), "superfluid self update");
1599 let home = dir.join("home");
1600 let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1601 let refused = update(&home, &catalog, &packaged, &Options::default()).unwrap_err();
1602 assert!(refused.contains("installed by pip"), "{refused}");
1603 assert!(list(&home, &packaged).contains("`pip install --upgrade superfluid-cli` updates it"));
1604 assert!(running_from(std::env::current_exe().unwrap().parent().unwrap()) || true, "other processes may or may not run from here");
1605 assert!(!running_from(&dir.join("nowhere")));
1606 let _ = std::fs::remove_dir_all(&dir);
1607 }
1608
1609 #[test]
1610 fn an_update_places_the_release_beside_the_old_one_flips_current_and_keeps_one_back() {
1611 let dir = scratch("update");
1612 let home = dir.join("home");
1613 let prefix = dir.join("local");
1614 let p = listing(&dir, &[("0.1.3", "2026-10-01", Some(1)), ("0.1.4", "2026-10-10", Some(1)), ("0.1.5", "2026-10-12", Some(1))]);
1615 let from = |version: Option<&str>, dry_run: bool| Options { version: version.map(str::to_string), dry_run, source: Some(p.to_string_lossy().to_string()), ..Options::default() };
1616 let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1617 let unpacked = release_tree(&dir.join("unpacked").join("superfluid-x"), "0.1.3");
1618 let r = install_from(&home, &catalog, &unpacked, Some(&prefix.join("bin"))).unwrap();
1619 assert!(r.changed, "{:?}", r.lines);
1620 let versions = Versions::of(&home);
1621 assert_eq!(versions.current().as_deref(), Some("0.1.3"));
1622 assert_eq!(std::fs::read_link(prefix.join("bin").join("superfluid")).unwrap(), versions.dir.join("current").join("bin").join("superfluid"));
1623 assert!(prefix.join("bin").join("superfluid-0.1.3").exists() && prefix.join("bin").join("superfluid-workerd").exists());
1624 assert!(!unpacked.exists(), "moved, not copied");
1625 let exe = versions.version_dir("0.1.3").join("bin").join("superfluid");
1626 let r = update(&home, &catalog, &exe, &from(None, true)).unwrap();
1627 assert!(r.lines[0].starts_with("0.1.3 -> 0.1.5 (published 2026-10-12;") && !r.changed, "{:?}", r.lines);
1628 assert_eq!(versions.on_disk(), ["0.1.3"]);
1629 let r = update(&home, &catalog, &exe, &from(None, false)).unwrap();
1630 assert!(r.changed, "{:?}", r.lines);
1631 assert_eq!(versions.current().as_deref(), Some("0.1.5"));
1632 assert_eq!(versions.previous().as_deref(), Some("0.1.3"));
1633 assert_eq!(versions.on_disk(), ["0.1.5", "0.1.3"]);
1634 assert!(r.lines.iter().any(|l| l == "0.1.3 stays installed: `superfluid self use 0.1.3` goes back to it"), "{:?}", r.lines);
1635 assert!(prefix.join("bin").join("superfluid-0.1.5").exists());
1636 assert!(!versions.dir.join(".staging").join("0.1.5").exists());
1637 let r = update(&home, &catalog, &versions.version_dir("0.1.5").join("bin").join("superfluid"), &from(None, false)).unwrap();
1638 assert!(!r.changed && r.lines[0].starts_with("superfluid 0.1.5 is current"), "{:?}", r.lines);
1639 release_tree(&versions.version_dir("0.9.0"), "0.9.0");
1640 versions.set_current("0.9.0").unwrap();
1641 let r = update(&home, &catalog, &exe, &from(None, false)).unwrap();
1642 assert!(!r.changed && r.lines[0].starts_with("superfluid 0.9.0 is current; the newest release that fits is 0.1.5"), "{:?}", r.lines);
1643 versions.set_current("0.1.5").unwrap();
1644 std::fs::remove_dir_all(versions.version_dir("0.9.0")).unwrap();
1645 std::fs::write(versions.dir.join("previous"), "0.1.3\n").unwrap();
1646 let r = update(&home, &catalog, &exe, &from(Some("v0.1.3"), false)).unwrap();
1647 assert!(r.changed && versions.current().as_deref() == Some("0.1.3") && versions.previous().as_deref() == Some("0.1.5"), "{:?}", r.lines);
1648 let r = update(&home, &catalog, &exe, &from(Some("0.1.4"), false)).unwrap();
1649 assert_eq!((versions.current().as_deref(), versions.previous().as_deref()), (Some("0.1.4"), Some("0.1.3")));
1650 assert_eq!(versions.on_disk(), ["0.1.4", "0.1.3"], "0.1.5 was neither current nor previous: {:?}", r.lines);
1651 assert!(prefix.join("bin").join("superfluid-0.1.5").symlink_metadata().is_err(), "its link went with it, not left dangling");
1652 std::fs::write(versions.version_dir("0.1.3").join("keep"), "").unwrap();
1653 let r = use_version(&home, &catalog, &exe, "0.1.3").unwrap();
1654 assert!(r.changed && versions.current().as_deref() == Some("0.1.3"));
1655 assert!(use_version(&home, &catalog, &exe, "..").unwrap_err().contains("is not a version"));
1656 assert_eq!(list(&home, &exe).lines().filter(|l| l.contains("current") || l.contains("previous")).count(), 2);
1657 let unreachable = Options { source: Some("/nonexistent/releases.json".into()), ..Options::default() };
1658 assert!(update(&home, &catalog, &exe, &unreachable).unwrap_err().contains("releases.json"), "the listing is needed for an update");
1659 let _ = std::fs::remove_dir_all(&dir);
1660 }
1661
1662 #[test]
1663 fn an_installed_version_is_kept_and_only_a_proved_copy_replaces_a_broken_one() {
1664 let dir = scratch("reinstall");
1665 let home = dir.join("home");
1666 let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1667 let versions = Versions::of(&home);
1668 let r = install_from(&home, &catalog, &release_tree(&dir.join("first").join("superfluid-x"), "0.1.3"), None).unwrap();
1669 assert!(r.changed);
1670 std::fs::write(versions.version_dir("0.1.3").join("mine"), "").unwrap();
1671 let r = install_from(&home, &catalog, &release_tree(&dir.join("second").join("superfluid-x"), "0.1.3"), None).unwrap();
1672 assert!(r.lines.iter().any(|l| l.contains("installed here already and works")), "{:?}", r.lines);
1673 assert!(versions.version_dir("0.1.3").join("mine").exists() && versions.current().as_deref() == Some("0.1.3"));
1674 let bad = release_tree(&dir.join("bad").join("superfluid-x"), "0.1.3");
1675 std::fs::write(bad.join("bin").join("superfluid"), "#!/bin/sh\nexit 1\n").unwrap();
1676 let e = install_from(&home, &catalog, &bad, None).unwrap_err();
1677 assert!(e.contains("did not say its version"), "refused before anything is touched: {e}");
1678 assert!(versions.version_dir("0.1.3").join("mine").exists() && versions.current().as_deref() == Some("0.1.3"));
1679 let wrong = release_tree(&dir.join("wrong").join("superfluid-x"), "0.1.3");
1680 std::fs::write(wrong.join("bin").join("superfluid"), "#!/bin/sh\n[ \"$1\" = --version ] && echo 'superfluid 0.1.3' && exit 0\nexit 1\n").unwrap();
1681 let r = install_from(&home, &catalog, &wrong, None).unwrap();
1682 assert!(r.lines.iter().any(|l| l.contains("the copy given was not used")), "{:?}", r.lines);
1683 assert!(versions.version_dir("0.1.3").join("mine").exists() && versions.current().as_deref() == Some("0.1.3"));
1684 std::fs::write(versions.binary("0.1.3"), "#!/bin/sh\nexit 1\n").unwrap();
1685 let e = install_from(&home, &catalog, &wrong, None).unwrap_err();
1686 assert!(e.contains("was not made current") && e.contains("runtimes --json failed"), "{e}");
1687 assert!(versions.version_dir("0.1.3").join("mine").exists(), "a copy that fails the probe replaces nothing");
1688 let good = release_tree(&dir.join("good").join("superfluid-x"), "0.1.3");
1689 let r = install_from(&home, &catalog, &good, None).unwrap();
1690 assert!(r.changed && !versions.version_dir("0.1.3").join("mine").exists() && version_of(&versions.binary("0.1.3")).is_ok(), "{:?}", r.lines);
1691 assert!(!versions.dir.join(".replaced-0.1.3").exists());
1692 let _ = std::fs::remove_dir_all(&dir);
1693 }
1694
1695 const SLEEP_ENV: &str = "SUPERFLUID_SELF_TEST_SLEEP";
1696
1697 #[test]
1699 fn sleeps_when_asked_to() {
1700 if std::env::var_os(SLEEP_ENV).is_some() {
1701 std::thread::sleep(Duration::from_secs(60));
1702 }
1703 }
1704
1705 #[test]
1706 fn a_replacement_that_fails_leaves_the_copy_set_aside_back_in_place() {
1707 let dir = scratch("replace");
1708 let (from, dest, aside) = (dir.join("from"), dir.join("dest"), dir.join(".replaced"));
1709 std::fs::create_dir_all(&from).unwrap();
1710 std::fs::create_dir_all(&dest).unwrap();
1711 std::fs::write(dest.join("mine"), "").unwrap();
1712 let half = |from: &Path, dest: &Path| -> Result<(), String> {
1713 std::fs::create_dir_all(dest).unwrap();
1714 std::fs::write(dest.join("partial"), "").unwrap();
1715 Err(format!("{} -> {}: no space", from.display(), dest.display()))
1716 };
1717 let e = replace_tree(&from, &dest, &aside, half).unwrap_err();
1718 assert!(e.contains("no space") && !e.contains("could not be put back"), "{e}");
1719 assert!(dest.join("mine").exists() && !dest.join("partial").exists(), "the copy set aside is back, whole");
1720 assert!(!aside.exists());
1721 let whole = |from: &Path, dest: &Path| -> Result<(), String> { copy_tree(from, dest) };
1722 std::fs::write(from.join("new"), "").unwrap();
1723 replace_tree(&from, &dest, &aside, whole).unwrap();
1724 assert!(dest.join("new").exists() && !dest.join("mine").exists() && !aside.exists());
1725 let _ = std::fs::remove_dir_all(&dir);
1726 }
1727
1728 struct Sleeper(std::process::Child);
1730
1731 impl Drop for Sleeper {
1732 fn drop(&mut self) {
1733 let _ = self.0.kill();
1734 let _ = self.0.wait();
1735 }
1736 }
1737
1738 fn sleeper_at(exe: &Path) -> Sleeper {
1743 std::fs::copy(std::env::current_exe().unwrap(), exe).unwrap();
1744 let mut cmd = Command::new(exe);
1745 cmd.args(["selfupdate::tests::sleeps_when_asked_to", "--exact", "--quiet"])
1746 .env(SLEEP_ENV, "1")
1747 .stdout(Stdio::null())
1748 .stderr(Stdio::null());
1749 Sleeper(crate::exec::when_not_busy(|| cmd.spawn()).unwrap())
1750 }
1751
1752 fn seen_running_from(dir: &Path) -> bool {
1754 (0..50).any(|_| {
1755 std::thread::sleep(Duration::from_millis(100));
1756 running_from(dir)
1757 })
1758 }
1759
1760 #[test]
1761 fn a_server_still_running_from_the_install_scripts_copy_keeps_its_workerd_until_it_stops() {
1762 let dir = scratch("legacy-running");
1763 let (home, prefix) = (dir.join("home"), dir.join("local"));
1764 let p = listing(&dir, &[("0.1.3", "2026-10-01", None)]);
1765 let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1766 release_tree(&prefix, "0.1.2");
1767 let bin = prefix.join("bin");
1768 let mut sleeper = sleeper_at(&bin.join("superfluid-workerd"));
1769 if !seen_running_from(&bin) {
1770 eprintln!("SKIP: this platform does not report the sleeper's executable");
1771 return;
1772 }
1773 let exe = bin.join("superfluid");
1774 let opts = Options { source: Some(p.to_string_lossy().to_string()), ..Options::default() };
1775 let r = update(&home, &catalog, &exe, &opts).unwrap();
1776 let versions = Versions::of(&home);
1777 assert_eq!(versions.current().as_deref(), Some("0.1.3"), "{:?}", r.lines);
1778 assert!(std::fs::symlink_metadata(&exe).unwrap().file_type().is_symlink(), "the command moved");
1779 assert!(bin.join("superfluid-workerd").symlink_metadata().unwrap().file_type().is_file(), "the running server's workerd stayed a file: {:?}", r.lines);
1780 assert!(bin.join("superfluid-noded").symlink_metadata().unwrap().file_type().is_file());
1781 assert!(prefix.join("libexec").join("superfluid").join("superfluid-worker-toy").exists(), "and its workers");
1782 assert!(versions.dir.join("migrating").exists());
1783 assert!(r.lines.iter().any(|l| l.contains("still runs from the copy")), "{:?}", r.lines);
1784 assert_eq!(versions.previous().as_deref(), Some("0.1.2"));
1785 versions.link(&bin, false).unwrap();
1786 assert!(bin.join("superfluid-workerd").symlink_metadata().unwrap().file_type().is_file());
1787 drop(std::mem::replace(&mut sleeper, Sleeper(Command::new("true").spawn().unwrap())));
1788 let notes = versions.link(&bin, false).unwrap();
1789 assert!(bin.join("superfluid-workerd").symlink_metadata().unwrap().file_type().is_symlink(), "{notes:?}");
1790 assert!(!prefix.join("libexec").join("superfluid").join("superfluid-worker-toy").exists());
1791 assert!(!versions.dir.join("migrating").exists());
1792 assert!(notes.iter().any(|n| n.contains("now links into")), "{notes:?}");
1793 let _ = std::fs::remove_dir_all(&dir);
1794 }
1795
1796 #[test]
1797 fn a_server_whose_own_command_became_a_link_still_holds_the_install_scripts_copy() {
1798 let dir = scratch("legacy-server");
1799 let (home, prefix) = (dir.join("home"), dir.join("local"));
1800 let versions = Versions::of(&home);
1801 release_tree(&versions.version_dir("0.1.3"), "0.1.3");
1802 versions.set_current("0.1.3").unwrap();
1803 release_tree(&prefix, "0.1.2");
1804 let bin = prefix.join("bin");
1805 let toy = prefix.join("libexec").join("superfluid").join("superfluid-worker-toy");
1806 let mut sleeper = sleeper_at(&bin.join("superfluid"));
1807 if !seen_running_from(&bin) {
1808 eprintln!("SKIP: this platform does not report the sleeper's executable");
1809 return;
1810 }
1811 let notes = versions.link(&bin, true).unwrap();
1812 assert!(bin.join("superfluid").symlink_metadata().unwrap().file_type().is_symlink(), "the command moved: {notes:?}");
1813 assert!(bin.join("superfluid-workerd").symlink_metadata().unwrap().file_type().is_file() && toy.exists(), "{notes:?}");
1814 std::fs::remove_file(versions.binary("0.1.3")).unwrap();
1817 let notes = versions.link(&bin, false).unwrap();
1818 assert!(bin.join("superfluid-workerd").symlink_metadata().unwrap().file_type().is_file(), "the server's workerd stayed a file: {notes:?}");
1819 assert!(toy.exists() && versions.dir.join("migrating").exists(), "and its workers: {notes:?}");
1820 assert!(notes.iter().any(|n| n.contains("still runs from the copy")), "{notes:?}");
1821 drop(std::mem::replace(&mut sleeper, Sleeper(Command::new("true").spawn().unwrap())));
1822 let note = std::fs::read_to_string(versions.dir.join("migrating")).unwrap();
1824 std::fs::write(versions.dir.join("migrating"), format!("{note}{} 1\n", std::process::id())).unwrap();
1825 let notes = versions.link(&bin, false).unwrap();
1826 assert!(bin.join("superfluid-workerd").symlink_metadata().unwrap().file_type().is_symlink(), "{notes:?}");
1827 assert!(!toy.exists() && !versions.dir.join("migrating").exists(), "{notes:?}");
1828 let _ = std::fs::remove_dir_all(&dir);
1829 }
1830
1831 #[test]
1832 fn the_install_script_over_an_earlier_copy_keeps_that_copy_to_go_back_to() {
1833 let dir = scratch("script-over-legacy");
1834 let (home, prefix) = (dir.join("home"), dir.join("local"));
1835 let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1836 release_tree(&prefix, "0.1.2");
1837 let tree = release_tree(&dir.join("unpacked").join("superfluid-x"), "0.1.3");
1838 let r = install_from(&home, &catalog, &tree, Some(&prefix.join("bin"))).unwrap();
1839 let versions = Versions::of(&home);
1840 assert_eq!((versions.current().as_deref(), versions.previous().as_deref()), (Some("0.1.3"), Some("0.1.2")), "{:?}", r.lines);
1841 assert_eq!(version_of(&versions.binary("0.1.2")).unwrap(), "0.1.2");
1842 for name in LINKED {
1843 assert!(prefix.join("bin").join(name).symlink_metadata().unwrap().file_type().is_symlink(), "{name}");
1844 }
1845 assert!(!prefix.join("libexec").join("superfluid").join("superfluid-worker-toy").exists());
1846 assert!(r.lines.iter().any(|l| l.contains("kept as 0.1.2")), "{:?}", r.lines);
1847 let _ = std::fs::remove_dir_all(&dir);
1848 }
1849
1850 #[test]
1851 fn a_half_finished_copy_of_the_install_scripts_copy_is_copied_again_not_taken_for_it() {
1852 let dir = scratch("half-kept");
1853 let (home, prefix) = (dir.join("home"), dir.join("local"));
1854 let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1855 let versions = Versions::of(&home);
1856 release_tree(&prefix, "0.1.2");
1857 std::fs::create_dir_all(versions.version_dir("0.1.2").join("bin")).unwrap();
1859 std::fs::copy(prefix.join("bin").join("superfluid"), versions.binary("0.1.2")).unwrap();
1860 let e = use_version(&home, &catalog, &versions.binary("0.1.2"), "0.1.2").unwrap_err();
1861 assert!(e.contains("no superfluid-workerd"), "{e}");
1862 let tree = release_tree(&dir.join("unpacked").join("superfluid-x"), "0.1.3");
1863 let r = install_from(&home, &catalog, &tree, Some(&prefix.join("bin"))).unwrap();
1864 assert_eq!(versions.previous().as_deref(), Some("0.1.2"), "{:?}", r.lines);
1865 assert!(versions.version_dir("0.1.2").join("bin").join("superfluid-workerd").is_file(), "{:?}", r.lines);
1866 assert!(versions.version_dir("0.1.2").join("libexec").join("superfluid").join("superfluid-worker-toy").exists(), "the workers came along before the originals went");
1867 assert!(!prefix.join("libexec").join("superfluid").join("superfluid-worker-toy").exists());
1868 assert!(!versions.dir.join(".staging").join("kept-0.1.2").exists());
1869 assert!(use_version(&home, &catalog, &versions.binary("0.1.3"), "0.1.2").unwrap().changed);
1870 let _ = std::fs::remove_dir_all(&dir);
1871 }
1872
1873 #[test]
1874 fn a_download_that_does_not_check_out_leaves_nothing_behind() {
1875 let dir = scratch("bad-download");
1876 let home = dir.join("home");
1877 let p = listing(&dir, &[("0.1.3", "2026-10-01", None)]);
1878 std::fs::write(dir.join(format!("superfluid-{}-0.1.3.tar.gz.sha256", target().unwrap())), format!("{} x\n", "0".repeat(64))).unwrap();
1879 let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1880 let versions = Versions::of(&home);
1881 release_tree(&versions.version_dir("0.1.2"), "0.1.2");
1882 versions.set_current("0.1.2").unwrap();
1883 let opts = Options { source: Some(p.to_string_lossy().to_string()), ..Options::default() };
1884 let e = update(&home, &catalog, &versions.binary("0.1.2"), &opts).unwrap_err();
1885 assert!(!versions.dir.join(".staging").join("0.1.3").exists(), "{e}");
1886 assert!(!versions.version_dir("0.1.3").exists() && versions.current().as_deref() == Some("0.1.2"), "{e}");
1887 let _ = std::fs::remove_dir_all(&dir);
1888 }
1889
1890 #[test]
1891 fn an_older_version_that_cannot_be_removed_does_not_fail_the_install() {
1892 let dir = scratch("prune-fails");
1893 let home = dir.join("home");
1894 let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1895 let versions = Versions::of(&home);
1896 release_tree(&versions.version_dir("0.1.0"), "0.1.0");
1897 release_tree(&versions.version_dir("0.1.1"), "0.1.1");
1898 versions.set_current("0.1.1").unwrap();
1899 let stuck = versions.version_dir("0.1.0").join("bin");
1900 std::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o555)).unwrap();
1901 let r = install_from(&home, &catalog, &release_tree(&dir.join("unpacked").join("superfluid-x"), "0.1.2"), None);
1902 std::fs::set_permissions(&stuck, std::fs::Permissions::from_mode(0o755)).unwrap();
1903 let r = r.unwrap();
1904 assert!(r.changed && versions.current().as_deref() == Some("0.1.2"), "{:?}", r.lines);
1905 assert!(r.lines.iter().any(|l| l.starts_with("an older version was not removed (")), "{:?}", r.lines);
1906 let _ = std::fs::remove_dir_all(&dir);
1907 }
1908
1909 #[test]
1910 fn a_switch_waits_for_an_update_that_holds_the_lock() {
1911 let dir = scratch("lock");
1912 let home = dir.join("home");
1913 let versions = Versions::of(&home);
1914 release_tree(&versions.version_dir("0.1.3"), "0.1.3");
1915 release_tree(&versions.version_dir("0.1.4"), "0.1.4");
1916 versions.set_current("0.1.4").unwrap();
1917 let held = crate::pulls::Lock::take(&versions.dir, "self", "update of superfluid").unwrap();
1918 let exe = versions.binary("0.1.4");
1919 let (home2, exe2) = (home.clone(), exe.clone());
1920 let switching = std::thread::spawn(move || {
1921 let catalog = Catalog::new(home2.clone(), None, home2.join("superfluid-workerd"));
1922 use_version(&home2, &catalog, &exe2, "0.1.3").map(|r| r.changed)
1923 });
1924 std::thread::sleep(Duration::from_millis(300));
1925 assert!(!switching.is_finished(), "the switch does not run while the lock is held");
1926 assert_eq!(versions.current().as_deref(), Some("0.1.4"));
1927 drop(held);
1928 assert_eq!(switching.join().unwrap(), Ok(true));
1929 assert_eq!(versions.current().as_deref(), Some("0.1.3"));
1930 let _ = std::fs::remove_dir_all(&dir);
1931 }
1932
1933 #[test]
1934 fn the_check_goes_on_from_the_cache_and_asks_again_when_due() {
1935 let dir = scratch("tick");
1936 let home = dir.join("home");
1937 std::fs::create_dir_all(&home).unwrap();
1938 let p = listing(&dir, &[("99.0.0", "2026-01-01", None)]);
1939 let source = p.to_string_lossy().to_string();
1940 let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1941 let first = tick(&home, &catalog, &source).unwrap();
1942 assert!(first.starts_with("superfluid 99.0.0 is available"), "{first}");
1943 assert_eq!(tick(&home, &catalog, "/nonexistent/releases.json"), None, "not due: the cache answers, nothing is read, and the release was announced already");
1944 let stale = Check { checked_at: 1, ..Check::read(&home).unwrap() };
1945 stale.write(&home).unwrap();
1946 assert_eq!(tick(&home, &catalog, "/nonexistent/releases.json"), None, "due, but the listing is unreachable: nothing, no panic");
1947 assert_eq!(Check::read(&home).unwrap().checked_at, 1, "an unreachable listing leaves the cache as it was");
1948 let p = listing(&dir.join("later"), &[("99.0.0", "2026-01-01", None), ("99.5.0", "2026-01-02", None)]);
1949 let later = tick(&home, &catalog, &p.to_string_lossy()).unwrap();
1950 assert!(later.starts_with("superfluid 99.5.0 is available"), "a release published since is announced on the next due check: {later}");
1951 let _ = std::fs::remove_dir_all(&dir);
1952 }
1953
1954 #[test]
1955 fn a_release_that_does_not_start_or_drive_the_engine_is_not_made_current() {
1956 let dir = scratch("probe");
1957 let home = dir.join("home");
1958 let versions = Versions::of(&home);
1959 let tree = release_tree(&versions.version_dir("0.5.0"), "0.5.0");
1960 std::fs::write(
1961 tree.join("bin").join("superfluid"),
1962 "#!/bin/sh\nif [ \"$1\" = runtimes ]; then echo '[{\"id\":\"basert\",\"ready\":\"libbaseRT states ABI 2\"}]'; else echo 'superfluid 0.5.0'; fi\n",
1963 )
1964 .unwrap();
1965 assert!(probe(&versions, "0.5.0", &home, &Engine::None).is_ok(), "no engine in use: starting is enough");
1966 let e = probe(&versions, "0.5.0", &home, &Engine::Abi(1)).unwrap_err();
1967 assert!(e.contains("cannot drive the engine here: libbaseRT states ABI 2"), "{e}");
1968 std::fs::write(tree.join("bin").join("superfluid"), "#!/bin/sh\necho 'superfluid ..'\n").unwrap();
1969 assert!(probe(&versions, "0.5.0", &home, &Engine::None).unwrap_err().contains("did not say its version"));
1970 std::fs::write(tree.join("bin").join("superfluid"), "#!/bin/sh\necho 'superfluid 0.4.9'\n").unwrap();
1971 assert!(probe(&versions, "0.5.0", &home, &Engine::None).unwrap_err().contains("says it is 0.4.9"));
1972 std::fs::write(tree.join("bin").join("superfluid"), "#!/bin/sh\nexec sleep 30\n").unwrap();
1974 let began = std::time::Instant::now();
1975 let e = output_within(Command::new(versions.binary("0.5.0")).arg("--version"), Duration::from_secs(1)).unwrap_err();
1976 assert!(e.contains("did not answer within 1s") && began.elapsed() < Duration::from_secs(10), "{e}");
1977 let _ = std::fs::remove_dir_all(&dir);
1978 }
1979
1980 #[test]
1981 fn the_in_place_install_is_migrated_into_the_layout() {
1982 let dir = scratch("migrate");
1983 let (home, prefix) = (dir.join("home"), dir.join("local"));
1984 let p = listing(&dir, &[("0.1.3", "2026-10-01", None)]);
1985 let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
1986 release_tree(&prefix, "0.1.2");
1987 let exe = prefix.join("bin").join("superfluid");
1988 assert!(matches!(installed_as(&exe), Installed::InPlace { .. }));
1989 let opts = Options { source: Some(p.to_string_lossy().to_string()), ..Options::default() };
1990 let r = update(&home, &catalog, &exe, &opts).unwrap();
1991 let versions = Versions::of(&home);
1992 assert_eq!(versions.current().as_deref(), Some("0.1.3"), "{:?}", r.lines);
1993 assert!(std::fs::symlink_metadata(&exe).unwrap().file_type().is_symlink(), "the copy became a link");
1994 assert_eq!(std::fs::read_link(prefix.join("bin").join("superfluid-noded")).unwrap(), versions.dir.join("current").join("bin").join("superfluid-noded"));
1995 assert!(!prefix.join("libexec").join("superfluid").join("superfluid-worker-toy").exists(), "the old workers are gone");
1996 assert_eq!(versions.previous().as_deref(), Some("0.1.2"), "{:?}", r.lines);
1997 assert_eq!(version_of(&versions.binary("0.1.2")).unwrap(), "0.1.2");
1998 assert!(versions.version_dir("0.1.2").join("libexec").join("superfluid").join("superfluid-worker-toy").exists(), "with its workers");
1999 assert_eq!(versions.on_disk(), ["0.1.3", "0.1.2"]);
2000 assert_eq!(versions.prefix().as_deref(), Some(std::fs::canonicalize(prefix.join("bin")).unwrap().as_path()));
2001 assert!(r.lines.iter().any(|l| l.contains("now links into")), "{:?}", r.lines);
2002 let _ = std::fs::remove_dir_all(&dir);
2003 }
2004
2005 #[test]
2006 fn the_daily_check_records_the_newest_release_and_announces_it_once() {
2007 let dir = scratch("check");
2008 let home = dir.join("home");
2009 std::fs::create_dir_all(&home).unwrap();
2010 let p = listing(&dir, &[("99.0.0", "2026-01-01", None), ("99.1.0", "2099-01-01", None)]);
2011 let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
2012 let c = check_now(&home, &catalog, Some(HOLD_OFF_DAYS), &p.to_string_lossy()).unwrap();
2013 assert_eq!(c.newest.as_deref(), Some("99.0.0"), "99.1.0 is inside the hold-off");
2014 assert!(!c.due());
2015 assert_eq!(Check::read(&home), Some(c.clone()));
2016 let line = announce(&home, c.clone(), "superfluid self update").unwrap();
2017 assert!(line.starts_with("superfluid 99.0.0 is available (this is ") && line.contains("`superfluid self update`"), "{line}");
2018 assert_eq!(announce(&home, Check::read(&home).unwrap(), "superfluid self update"), None, "announced once");
2019 let again = Check { notified: None, ..c.clone() };
2020 assert!(announce(&home, again, "brew upgrade superfluid").unwrap().contains("`brew upgrade superfluid` installs it"));
2021 assert!(Check::read(&home).unwrap().describe().starts_with("newest release: 99.0.0 (published 2026-01-01;"));
2022 let stale = Check { checked_at: 1, ..Check::default() };
2023 assert!(stale.due());
2024 let _ = std::fs::remove_dir_all(&dir);
2025 }
2026
2027 fn logging_script(path: &Path, log: &Path) {
2029 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2030 std::fs::write(path, format!("#!/bin/sh\necho \"$*|${{PATH%%:*}}\" >> {}\n", log.display())).unwrap();
2031 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
2032 }
2033
2034 fn packaged(root: &Path, named: Option<&str>) -> PathBuf {
2036 release_tree(root, "0.1.5");
2037 if let Some(line) = named {
2038 std::fs::write(root.join(INSTALLED_BY), format!("{line}\n")).unwrap();
2039 }
2040 std::fs::canonicalize(root.join("bin").join("superfluid")).unwrap()
2041 }
2042
2043 #[test]
2044 fn each_package_manager_is_run_from_the_installation_that_owns_the_copy_or_named() {
2045 let dir = scratch("delegate");
2046 let brew_copy = packaged(&dir.join("brew/Cellar/superfluid/0.1.5/libexec"), Some("Homebrew: brew upgrade superfluid"));
2048 assert!(matches!(delegate(&brew_copy), Some(Delegate::Name { ref why, .. }) if why.starts_with("there is no ")));
2049 logging_script(&dir.join("brew/bin/brew"), &dir.join("log"));
2050 let brew = std::fs::canonicalize(dir.join("brew/bin/brew")).unwrap();
2051 assert_eq!(
2052 delegate(&brew_copy),
2053 Some(Delegate::Run {
2054 manager: "Homebrew".into(),
2055 command: "brew upgrade superfluid".into(),
2056 argv: vec![brew.display().to_string(), "upgrade".into(), "superfluid".into()],
2057 path_first: None,
2058 })
2059 );
2060 std::fs::create_dir_all(dir.join("brew/var/homebrew/pinned")).unwrap();
2061 std::fs::write(dir.join("brew/var/homebrew/pinned/superfluid"), "").unwrap();
2062 assert!(matches!(delegate(&brew_copy), Some(Delegate::Name { ref why, .. }) if why.contains("`brew unpin superfluid` first")));
2063 let npm_line = Some("npm: npm install --global @basecompute/superfluid@latest");
2065 let global = packaged(&dir.join("node/lib/node_modules/@basecompute/superfluid-darwin-arm64/release"), npm_line);
2066 logging_script(&dir.join("node/bin/npm"), &dir.join("log"));
2067 let bin = std::fs::canonicalize(dir.join("node/bin")).unwrap();
2068 match delegate(&global) {
2069 Some(Delegate::Run { argv, path_first, .. }) => {
2070 let prefix = std::fs::canonicalize(dir.join("node")).unwrap().display().to_string();
2071 assert_eq!(argv[1..], ["install", "--global", "--prefix", &prefix, "@basecompute/superfluid@latest"]);
2072 assert_eq!(path_first, Some(bin));
2073 }
2074 other => panic!("{other:?}"),
2075 }
2076 let project = packaged(&dir.join("app/node_modules/@basecompute/superfluid-darwin-arm64/release"), npm_line);
2077 assert!(matches!(delegate(&project), Some(Delegate::Name { ref why, .. }) if why.contains("update it in that project")));
2078 let pip_line = Some("pip: pip install --upgrade superfluid-cli");
2080 let env = dir.join("venv");
2081 let pip_copy = packaged(&env.join("lib/python3.12/site-packages/superfluid_cli/release"), pip_line);
2082 logging_script(&env.join("bin/python"), &dir.join("log"));
2083 let python = std::fs::canonicalize(env.join("bin/python")).unwrap();
2084 assert!(matches!(delegate(&pip_copy), Some(Delegate::Name { ref command, .. })
2085 if *command == format!("{} -m pip install --upgrade superfluid-cli", python.display())));
2086 let pipx = packaged(&dir.join(".local/pipx/venvs/superfluid-cli/lib/python3.12/site-packages/superfluid_cli/release"), pip_line);
2087 let d = delegate(&pipx).unwrap();
2088 assert_eq!((d.manager(), d.command()), ("pipx", "pipx upgrade superfluid-cli"));
2089 let uv = packaged(&dir.join(".local/share/uv/tools/superfluid-cli/lib/python3.12/site-packages/superfluid_cli/release"), pip_line);
2090 let d = delegate(&uv).unwrap();
2091 assert_eq!((d.manager(), d.command()), ("uv", "uv tool upgrade superfluid-cli"));
2092 let conda = packaged(&dir.join("conda/release"), Some("conda: conda update superfluid"));
2094 assert!(matches!(delegate(&conda), Some(Delegate::Name { ref command, .. }) if command == "conda update superfluid"));
2095 let _ = std::fs::remove_dir_all(&dir);
2096 }
2097
2098 #[test]
2099 fn the_superfluid_basert_installed_is_left_to_basert() {
2100 let dir = scratch("basert-owned");
2101 let copy = packaged(&dir.join("superfluid"), None);
2103 assert!(matches!(installed_as(©), Installed::InPlace { .. }), "without basert's marks it is the install script's");
2104 std::fs::write(dir.join("superfluid").join(BASERT_STAMP), "v0.1.5\n").unwrap();
2105 assert!(matches!(installed_as(©), Installed::Other(_)));
2106 let d = delegate(©).unwrap();
2108 assert_eq!((d.manager(), d.command()), ("basert", "basert superfluid install"));
2109 logging_script(&dir.join("basert"), &dir.join("log"));
2110 std::fs::write(dir.join("superfluid").join(INSTALLED_BY), "basert: basert superfluid install\n").unwrap();
2111 let basert = std::fs::canonicalize(dir.join("basert")).unwrap();
2112 assert_eq!(
2113 delegate(©),
2114 Some(Delegate::Run {
2115 manager: "basert".into(),
2116 command: "basert superfluid install".into(),
2117 argv: vec![basert.display().to_string(), "superfluid".into(), "install".into()],
2118 path_first: None,
2119 })
2120 );
2121 assert_eq!(update_command(©), "basert superfluid install");
2122 let home = dir.join("home");
2123 let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
2124 let opts = Options { version: Some("0.1.4".into()), yes: true, ..Options::default() };
2126 let refused = update(&home, &catalog, ©, &opts).unwrap_err();
2127 assert!(refused.contains("is basert's, which picks the release that serves its engine"), "{refused}");
2128 let opts = Options { yes: true, ..Options::default() };
2131 let mut hand_off = update(&home, &catalog, ©, &opts).unwrap().hand_off.expect("basert is handed off to");
2132 assert!(!dir.join("log").exists());
2133 assert!(crate::exec::when_not_busy(|| hand_off.status()).unwrap().success());
2134 assert!(std::fs::read_to_string(dir.join("log")).unwrap().starts_with("superfluid install|"));
2135 assert!(!home.join("versions").exists());
2136 let _ = std::fs::remove_dir_all(&dir);
2137 }
2138
2139 #[test]
2140 fn a_package_managers_update_is_asked_for_dry_run_or_run_with_yes() {
2141 let dir = scratch("by-manager");
2142 let copy = packaged(&dir.join("brew/Cellar/superfluid/0.1.5/libexec"), Some("Homebrew: brew upgrade superfluid"));
2143 logging_script(&dir.join("brew/bin/brew"), &dir.join("log"));
2144 let home = dir.join("home");
2145 let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
2146 let refused = update(&home, &catalog, ©, &Options::default()).unwrap_err();
2148 assert!(refused.contains("run `brew upgrade superfluid`, or `superfluid self update --yes`"), "{refused}");
2149 let dry = update(&home, &catalog, ©, &Options { dry_run: true, yes: true, ..Options::default() }).unwrap();
2150 assert!(dry.lines[0].contains("`self update` would run ") && dry.lines[0].ends_with("brew/bin/brew upgrade superfluid"), "{:?}", dry.lines);
2151 let pinned = Options { version: Some("0.1.4".into()), yes: true, ..Options::default() };
2152 assert!(update(&home, &catalog, ©, &pinned).unwrap_err().contains("which chooses its version"));
2153 assert!(!dir.join("log").exists());
2154 let done = update(&home, &catalog, ©, &Options { yes: true, ..Options::default() }).unwrap();
2155 assert!(done.changed && done.hand_off.is_none());
2156 assert!(std::fs::read_to_string(dir.join("log")).unwrap().starts_with("upgrade superfluid|"));
2157 let _ = std::fs::remove_dir_all(&dir);
2158 }
2159}