Skip to main content

superfluid_daemon/
selfupdate.rs

1//! `superfluid self update|list|use|check|install`: releases side by side under `$SUPERFLUID_HOME/versions`, a
2//! `current` link the commands on PATH go through, and the daily check `serve` runs.
3
4use 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";
17/// A release younger than this is not announced by the daily check, so early reports land first.
18pub 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";
21/// Where the release listing is read from, for a mirror or a test: a URL or a file.
22pub const LISTING_ENV: &str = "SUPERFLUID_RELEASES";
23const LINKED: &[&str] = &["superfluid", "superfluid-workerd", "superfluid-noded"];
24/// The file at the root of a release a package manager installed (Homebrew, pip, npm), one line `<manager>: <command>`:
25/// the copy is that manager's to update, with that command.
26pub const INSTALLED_BY: &str = "installed-by";
27/// The stamp basert writes at the root of the superfluid it installs for `basert serve`, before it also wrote an
28/// `installed-by` file naming itself: such a copy is basert's, not the install script's.
29const BASERT_STAMP: &str = ".release";
30/// How long a new copy's `--version` or `runtimes --json` may take: it runs under the lock, which a copy that hangs
31/// would hold for every later update. Longer than the runtime checks `runtimes --json` makes, a minute each at most.
32const 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
63/// The release asset name for this build's platform.
64pub 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
78/// Whether `v` can name a version directory: it starts with a digit and holds only what a version does, so it is one
79/// path component and never empty, `.` or `..`, which would make the version's directory the layout or above it.
80fn 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/// A part of a pre-release, `rc` or `1` in `0.2.0-rc.1`: a number comes before a word, as semver orders them.
85#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
86enum PreRelease {
87    Number(u64),
88    Word(String),
89}
90
91/// A version's place in release order: by its numbers, then a release after the pre-releases that lead to it
92/// (`0.2.0-rc.1` before `0.2.0`, which the numbers alone put the other way), then the pre-release part by part; build
93/// metadata after a `+` counts for nothing.
94fn 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
106/// `$SUPERFLUID_HOME/versions`: a directory per release, `current`, `previous`, and `prefix` naming the bin directory that links here.
107pub 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    /// The complete versions here, newest first.
135    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    /// Point `current` at `v`, atomically, remembering what it pointed at.
155    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    /// Removes every version but `current`, `previous`, one with a `keep` file, and one a running process started from.
169    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    /// The names in `prefix` that are the install script's real files, not links.
184    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    /// The links in `prefix`: the three commands through `current`, and `superfluid-<v>` per version. The install script's
189    /// real files are replaced on a migration, except `superfluid-workerd` while a server still runs from it (it starts its
190    /// workers by that path), which a `migrating` note has the next link finish once nothing runs from there. The note
191    /// names the processes that ran from there, by pid and start time: once a link is renamed over a server's own
192    /// executable, macOS may give no path for it, and a server found by its path alone would be missed.
193    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        // A process the note names counts while it is the one that started then, whatever path is given for it now.
201        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/// Where the running executable came from, which decides who updates it.
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub enum Installed {
294    /// `<home>/versions/<v>/bin/superfluid`: ours to update.
295    Managed { versions: PathBuf, version: String },
296    /// The install script's layout before versions existed: real files in `<prefix>/bin` and `<prefix>/libexec/superfluid`.
297    InPlace { prefix: PathBuf },
298    /// A package manager (an `installed-by` file says which), `cargo install`, or a source build.
299    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        // A package keeps the release's layout too, so it is told apart by its file, not by where it is.
312        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
322/// The package manager that installed `exe` and the command that updates it, from the `installed-by` file at the root
323/// of its release.
324fn 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
330/// The command that updates the running superfluid, for the line `serve` prints when a newer release is out.
331fn 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/// How a copy another tool installed is updated: by that tool's own command, which `self update` runs (after asking),
350/// or which it only names, saying why. The command is built here from the manager's name, never read from the
351/// `installed-by` file, whose text is for showing.
352#[derive(Debug, Clone, PartialEq, Eq)]
353pub enum Delegate {
354    /// `argv` is run, its binary found from the copy's own path so that it is the installation that owns the copy;
355    /// `path_first` goes first on PATH (npm's `node`, beside it). `command` is what a user would type.
356    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
374/// The delegate for the copy at the real path `exe` (`<root>/bin/superfluid`), from the manager its `installed-by`
375/// file names, or from where it is for packages published before they carried the file; `None` for a copy no package
376/// manager or basert placed.
377fn 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    // pipx and uv keep the wheel in a venv of their own, whose file says pip.
382    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
408/// Whether this user may write `path`, as the manager would need to.
409fn 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    // SAFETY: `c` is a NUL-terminated path that outlives the call.
413    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
421/// `tool args…` with `tool` from PATH, or named when PATH has none.
422fn 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
436/// The superfluid basert installs for `basert serve` (`<basert dir>/superfluid`, or basert's data directory): basert
437/// picks the release that serves its engine. Run by the basert beside it, else the one on PATH.
438fn 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
454/// `brew upgrade <formula>` by the Homebrew whose Cellar holds the copy, unless the formula is pinned or that Homebrew
455/// is another user's.
456fn 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
479/// `npm install --global` by the npm whose global folder holds the copy, told that folder's prefix (npm's
480/// configuration or NPM_CONFIG_PREFIX may name another); a project's dependency (or pnpm's, yarn's, bun's) is that
481/// project's to update.
482fn 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
510/// pip is only named: the environment may be a project's, which a lock file (Poetry, uv, pip-tools) pins, or the
511/// system's, which refuses or needs root.
512fn pip(p: &str) -> Delegate {
513    // .../<env>/lib/python3.x/site-packages/superfluid_cli/release/bin/superfluid
514    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
523/// `cargo install superfluid` by the cargo beside the copy.
524fn 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
533/// A yes/no question on stderr, answered on stdin; an empty answer is yes.
534fn 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
542/// `self update` of a copy another tool installed: its manager's command, run after asking (or with `--yes`) when it
543/// is one superfluid runs, else named. Nothing runs while a server runs from the copy, which the manager replaces.
544/// basert is handed off to (`Report::hand_off`) rather than run: it refuses to replace a copy that anything runs from,
545/// this process included.
546fn 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    // Its workers start from the copy's own directories; only superfluid's programs count (a cargo install shares
566    // ~/.cargo/bin with other tools).
567    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        // The question may have waited long enough for a server to start.
590        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    /// YYYY-MM-DD
621    pub published: String,
622    pub tarball: Asset,
623    pub sha256_url: String,
624    /// The basert engine ABI the release declares in an asset name.
625    pub abi: Option<u32>,
626    pub compat_url: Option<String>,
627}
628
629/// The releases of the listing with a tarball for `target`, newest first; drafts and prereleases left out.
630pub 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/// What a release must declare to drive the baseRT engine here.
663#[derive(Debug, Clone, PartialEq, Eq)]
664pub enum Engine {
665    /// No engine, or none this superfluid can tell: the newest release fits.
666    None,
667    Abi(u32),
668    /// A 0.3.x engine, which states no number: the release must accept that line.
669    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
696/// Whether `release` drives the engine; `compat` fetches its compat.json when the asset names alone do not say.
697pub 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
713/// The newest release that fits, skipping ones published fewer than `hold_off` days ago when given.
714pub 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
718/// The release listing to read: `SUPERFLUID_RELEASES`, else GitHub's.
719pub fn listing_source() -> String {
720    std::env::var(LISTING_ENV).ok().filter(|s| !s.is_empty()).unwrap_or_else(|| LISTING.to_string())
721}
722
723/// A small text or JSON document: a file, or an https URL through curl.
724fn 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/// The other processes on this machine, each with its executable as a real path.
783#[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/// When `pid` started, in clock ticks since boot: with the pid, what tells a process from a later one given its number.
797#[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    // The command name, in parentheses, may hold spaces; the start time is the 20th field after it.
801    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    // SAFETY: with a null buffer, proc_listallpids only reports how many pids there are.
808    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    // SAFETY: the buffer is this function's, and its size in bytes is what is passed.
815    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            // SAFETY: proc_pidpath writes at most the buffer's length, which it is told.
822            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/// When `pid` started, in microseconds since the epoch.
829#[cfg(target_os = "macos")]
830fn started_at(pid: i32) -> Option<u64> {
831    // SAFETY: proc_bsdinfo is plain data, for which all zeroes is a value.
832    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    // SAFETY: proc_pidinfo writes at most `size` bytes into `info`, which is that big.
835    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
849/// Whether another process runs an executable under `dir`.
850pub fn running_from(dir: &Path) -> bool {
851    !processes_from(dir).is_empty()
852}
853
854/// The other processes running an executable under `dir`, by pid and when each started.
855fn 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    /// For a copy another tool installed: run its update without asking.
866    pub yes: bool,
867    /// The release listing; `listing_source()` when not set.
868    pub source: Option<String>,
869}
870
871/// What `self update` did, line by line.
872#[derive(Debug, Default)]
873pub struct Report {
874    pub lines: Vec<String>,
875    pub changed: bool,
876    /// The command `superfluid self update` replaces itself with, its own update left to that command.
877    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
886/// `cmd`'s output, or an error once it has run for `limit`, when it is killed.
887fn 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
904/// The proof a host runs: the copy's `--version` and `runtimes --json`, the basert row ready when an engine is in use.
905fn probe(versions: &Versions, v: &str, home: &Path, engine: &Engine) -> Result<(), String> {
906    probe_binary(&versions.binary(v), v, home, engine)
907}
908
909/// The same proof of a copy wherever it is, so a replacement is proved before anything it replaces is touched.
910fn probe_binary(binary: &Path, v: &str, home: &Path, engine: &Engine) -> Result<(), String> {
911    // A copy cut short can hold the command and nothing beside it; it starts, and its server finds no workerd.
912    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
960/// Download, check and unpack `release` into the layout; nothing is placed until the whole tree is verified, and what
961/// was downloaded goes whether or not it was placed.
962fn 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    // A move cut short across filesystems leaves part of a tree, which is not a version.
991    move_tree(&top, &dest).inspect_err(|_| {
992        let _ = std::fs::remove_dir_all(&dest);
993    })
994}
995
996/// The install script's in-place copy, kept under the layout as the version to go back to. It is copied beside the
997/// layout and moved in whole, so a copy cut short (a full disk, an interrupt) is never taken for the version and its
998/// original workers are not removed for it; a version missing a file of the copy is copied again.
999fn 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
1029/// Whether `dest` holds every command and worker of the in-place copy in `bin` and `libexec`.
1030fn 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
1038/// The install script's in-place copies become links into the layout (see `Versions::link` for a running server).
1039fn 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
1047/// `from` takes the place of `dest`, set aside until `from` is in place; a failed move leaves nothing at `dest` and
1048/// brings the copy back, and if that fails too both failures are reported.
1049fn 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
1072/// The older versions removed, once the new one is current: one that cannot be removed is said, not an error, since
1073/// the update itself is done; the next prune tries it again.
1074fn 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
1082/// `self update`: the newest release that fits, or the one named.
1083pub 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    // Taken before anything is read or decided, so an update queued behind another sees the layout it left.
1093    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    // Without a version named an update only moves forward: a build newer than every release stays.
1125    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    // The in-place copy goes under the layout first, as the version to go back to.
1143    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    // Pruned before the links are made, so a removed version's `superfluid-<v>` goes with it.
1167    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
1179/// `self use <v>`.
1180pub 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
1208/// `self install --from <dir> [--prefix <dir>]`: an unpacked release placed
1209/// into the layout and made current; the install script's last step.
1210pub 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        // An installed version is kept as it is; a copy of it that works is not replaced.
1222        report.say(format!("superfluid {v} is installed here already and works; the copy given was not used"));
1223    } else {
1224        // Proved where it is, then set in place of a copy that does not work.
1225        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    // The install script's earlier copy under the prefix is kept as the version to go back to.
1229    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
1253/// `self list`.
1254pub 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/// `$SUPERFLUID_HOME/update-check.json`: the last read, the newest release that fit, and the version announced.
1293#[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    /// The newest release known, when it is newer than this one.
1327    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
1341/// Read the listing now and record the newest release that fits here and is past the hold-off.
1342pub 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
1359/// The one line `serve` prints when a newer release is out, once per release; `command` installs it.
1360fn 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
1370/// What `serve` runs once the server is up: one listing read a day at most, one line per new release.
1371pub 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            // Hourly, so a server that stays up learns of a later release, and a check another process made is not repeated.
1382            std::thread::sleep(Duration::from_secs(60 * 60));
1383        })
1384        .ok();
1385}
1386
1387/// One round: the cache when not yet due, else the listing; the line to print for a release not yet announced.
1388pub 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
1402/// `superfluid self <action> ...`; the exit code.
1403pub 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                    // Only returns when the command could not be run.
1439                    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    /// A release tree whose `superfluid` answers `--version` and `runtimes --json` as a real one would.
1493    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    /// A listing with one release per version, its assets as local files.
1510    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        // A package keeps the release's layout, and its `installed-by` file says whose it is.
1591        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    /// This test binary copied under a prefix and told to sleep: a server running from the install script's layout.
1698    #[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    /// Killed and waited for on every way out of a test.
1729    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    /// This test binary copied to `exe` and run from there, asleep: a server running from the install script's layout.
1739    /// A copy of this test binary rather than a system one: macOS kills a platform binary run from elsewhere. Started
1740    /// as `when_not_busy` starts a program: on Linux a child another test forks while the copy is still open for
1741    /// writing holds that write until it execs, and the copy cannot be run until then (ETXTBSY).
1742    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    /// Whether a process is seen running from `dir` within five seconds.
1753    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        // macOS gives the path of an executable a link was renamed over only while that name still leads somewhere: with
1815        // the link dangling it gives none, and the server is known by the note alone.
1816        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        // A process that took a recorded id later is not the one recorded.
1823        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        // What a copy cut short leaves: the command and nothing beside it.
1858        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        // A copy that hangs is killed, so the lock it runs under is let go.
1973        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    /// A script at `path` that appends its arguments and the first PATH entry to `log`.
2028    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    /// A packaged release at `root` (`<root>/bin/superfluid`) whose `installed-by` says `named`, by its real path.
2035    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        // Homebrew: the brew of the Cellar, unless pinned.
2047        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        // npm: a global folder's own npm, its bin first on PATH; a project's dependency is named.
2064        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        // pip is named, with its environment's python; pipx and uv are told apart by their venvs.
2079        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        // A manager superfluid does not run is named with the file's own command.
2093        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        // basert's layout: <dir>/basert beside <dir>/superfluid, which an older basert marked with its stamp alone.
2102        let copy = packaged(&dir.join("superfluid"), None);
2103        assert!(matches!(installed_as(&copy), 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(&copy), Installed::Other(_)));
2106        // No basert beside it: the one on PATH, when there is one.
2107        let d = delegate(&copy).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(&copy),
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(&copy), "basert superfluid install");
2122        let home = dir.join("home");
2123        let catalog = Catalog::new(home.clone(), None, home.join("superfluid-workerd"));
2124        // A version is basert's to pick.
2125        let opts = Options { version: Some("0.1.4".into()), yes: true, ..Options::default() };
2126        let refused = update(&home, &catalog, &copy, &opts).unwrap_err();
2127        assert!(refused.contains("is basert's, which picks the release that serves its engine"), "{refused}");
2128        // With --yes, basert is handed this process, not run beside it: it refuses while anything runs from the copy.
2129        // Nothing was moved under ~/.superfluid.
2130        let opts = Options { yes: true, ..Options::default() };
2131        let mut hand_off = update(&home, &catalog, &copy, &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        // Tests run without a terminal: without --yes the command is named, not run.
2147        let refused = update(&home, &catalog, &copy, &Options::default()).unwrap_err();
2148        assert!(refused.contains("run `brew upgrade superfluid`, or `superfluid self update --yes`"), "{refused}");
2149        let dry = update(&home, &catalog, &copy, &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, &copy, &pinned).unwrap_err().contains("which chooses its version"));
2153        assert!(!dir.join("log").exists());
2154        let done = update(&home, &catalog, &copy, &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}