Skip to main content

doiget_core/
install_info.rs

1//! What this binary is and how it was installed -- without a network call
2//! (#594).
3//!
4//! An agent ran a doiget four releases old through the one surface that
5//! consumes `oa_url` programmatically, and nothing in the session said so:
6//! `doiget_health` reported a version with nothing to compare it against.
7//! A version check against the latest release is a network call nobody asked
8//! for, which ADR-0015 rules out; `doiget version --check` exists for a user
9//! who does ask. What can be reported for free is the rest of the picture:
10//!
11//! - the **release channel** the version belongs to (`stable`, or `beta` for
12//!   a `-beta.N` build);
13//! - **which binary is running** -- `current_exe`, the thing an MCP config
14//!   names by path and a user never sees;
15//! - **how it was installed**, from the manifest `scripts/install.sh` /
16//!   `install.ps1` leave beside the binary, or else from where the binary
17//!   lives (npm, cargo, Homebrew, Nix, a Claude Desktop `.mcpb` extension);
18//! - the **command that updates it** for that install method.
19//!
20//! A manifest whose version differs from the running binary means the file
21//! was replaced by something other than the installer, which is said too.
22
23use camino::Utf8PathBuf;
24use serde::{Deserialize, Serialize};
25
26/// File name of the manifest the installers write next to the binary.
27pub const MANIFEST_NAME: &str = "doiget.install.json";
28
29/// What an installer recorded about the binary it placed.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct InstallManifest {
32    /// `install.sh` or `install.ps1`.
33    pub installer: String,
34    /// The version it installed.
35    pub version: String,
36    /// The release asset.
37    #[serde(default)]
38    pub asset: Option<String>,
39    /// The verified SHA-256 of that asset.
40    #[serde(default)]
41    pub sha256: Option<String>,
42    /// RFC 3339 UTC.
43    #[serde(default)]
44    pub installed_at: Option<String>,
45}
46
47/// The build and install report.
48#[derive(Debug, Clone, Serialize)]
49pub struct InstallInfo {
50    /// `CARGO_PKG_VERSION` of the running binary.
51    pub version: &'static str,
52    /// `stable`, or `beta` for a pre-release version.
53    pub channel: &'static str,
54    /// The running binary, when the OS reports it.
55    pub binary: Option<Utf8PathBuf>,
56    /// How it was installed: `install.sh`, `install.ps1`, `npm`, `cargo`,
57    /// `homebrew`, `nix`, `mcpb`, or `unknown`.
58    pub method: &'static str,
59    /// The installer's manifest, when one sits beside the binary.
60    pub manifest: Option<InstallManifest>,
61    /// `false` when a manifest names a different version than the running
62    /// binary: the file was replaced other than by the installer.
63    pub manifest_matches: Option<bool>,
64    /// How to update an install of this kind. doiget never updates itself.
65    pub update: &'static str,
66    /// How to compare with the latest release: an explicit request, never
67    /// made on the user's behalf (ADR-0015).
68    pub check: &'static str,
69}
70
71/// Report on the running binary. Reads at most one small local file.
72#[must_use]
73pub fn install_info() -> InstallInfo {
74    let binary = std::env::current_exe()
75        .ok()
76        .and_then(|p| std::fs::canonicalize(&p).ok().or(Some(p)))
77        .and_then(|p| Utf8PathBuf::from_path_buf(p).ok());
78    let manifest = binary
79        .as_ref()
80        .and_then(|b| b.parent().map(|d| d.join(MANIFEST_NAME)))
81        .and_then(|p| std::fs::read_to_string(p).ok())
82        .and_then(|s| parse_manifest(&s));
83    describe(crate::VERSION, binary, manifest)
84}
85
86/// The manifest's text as an [`InstallManifest`], or `None` when it is not
87/// one: an unreadable manifest is reported as no manifest, and the method is
88/// read from the path instead. A leading BOM is allowed -- Windows
89/// PowerShell 5.1 writes one with `-Encoding UTF8`, and older `install.ps1`
90/// runs did.
91#[must_use]
92pub fn parse_manifest(text: &str) -> Option<InstallManifest> {
93    serde_json::from_str(text.trim_start_matches('\u{feff}')).ok()
94}
95
96/// What an installer records when the new binary would not report its
97/// version: it names no version, so it is compared with none.
98pub const UNKNOWN_VERSION: &str = "unknown";
99
100/// [`install_info`] from its inputs, for tests.
101#[must_use]
102pub fn describe(
103    version: &'static str,
104    binary: Option<Utf8PathBuf>,
105    manifest: Option<InstallManifest>,
106) -> InstallInfo {
107    let method = match &manifest {
108        Some(m) if m.installer == "install.ps1" => "install.ps1",
109        Some(_) => "install.sh",
110        None => binary
111            .as_ref()
112            .map_or("unknown", |b| method_from_path(b.as_str())),
113    };
114    let manifest_matches = manifest
115        .as_ref()
116        .filter(|m| m.version != UNKNOWN_VERSION)
117        .map(|m| m.version == version);
118    InstallInfo {
119        version,
120        channel: if version.contains('-') {
121            "beta"
122        } else {
123            "stable"
124        },
125        binary,
126        method,
127        manifest,
128        manifest_matches,
129        update: update_command(method),
130        check: "doiget version --check (asks GitHub Releases; doiget never checks on its own)",
131    }
132}
133
134/// Infer the install method from where the binary lives.
135fn method_from_path(path: &str) -> &'static str {
136    let p = path.replace('\\', "/").to_lowercase();
137    if p.contains("/node_modules/") {
138        "npm"
139    } else if p.contains("/.cargo/bin/") {
140        "cargo"
141    } else if p.contains("/cellar/") || p.contains("/homebrew/") || p.contains("/linuxbrew/") {
142        "homebrew"
143    } else if p.starts_with("/nix/store/") {
144        "nix"
145    } else if p.contains("claude extensions") || p.contains("/claude/extensions/") {
146        "mcpb"
147    } else {
148        "unknown"
149    }
150}
151
152fn update_command(method: &str) -> &'static str {
153    match method {
154        "install.sh" => {
155            "re-run: curl -fsSL https://raw.githubusercontent.com/QAtlasHub/doiget/main/scripts/install.sh | sh"
156        }
157        "install.ps1" => {
158            "re-run: irm https://raw.githubusercontent.com/QAtlasHub/doiget/main/scripts/install.ps1 | iex"
159        }
160        "npm" => {
161            "npm install -g doiget-cli@latest (npx -y doiget-cli runs the latest unless a version is pinned)"
162        }
163        "cargo" => "cargo install doiget-cli --locked",
164        "homebrew" => "brew upgrade doiget",
165        "nix" => "nix profile upgrade doiget (or update the flake input)",
166        "mcpb" => {
167            "install the .mcpb from the latest GitHub release: a Desktop Extension is not updated automatically"
168        }
169        _ => "reinstall from https://github.com/QAtlasHub/doiget/releases/latest",
170    }
171}
172
173#[cfg(test)]
174#[allow(clippy::expect_used, clippy::unwrap_used)]
175mod tests {
176    use super::*;
177
178    fn m(installer: &str, version: &str) -> InstallManifest {
179        InstallManifest {
180            installer: installer.into(),
181            version: version.into(),
182            asset: None,
183            sha256: None,
184            installed_at: None,
185        }
186    }
187
188    #[test]
189    fn the_channel_follows_the_version() {
190        assert_eq!(describe("0.9.0", None, None).channel, "stable");
191        assert_eq!(describe("0.9.0-beta.3", None, None).channel, "beta");
192        // Any pre-release is off the stable channel, not only -beta.N.
193        assert_eq!(describe("0.9.0-rc.1", None, None).channel, "beta");
194    }
195
196    #[test]
197    fn a_manifest_with_a_bom_parses_and_a_malformed_one_is_no_manifest() {
198        let json = r#"{"installer":"install.ps1","version":"0.9.0"}"#;
199        let with_bom = format!("\u{feff}{json}");
200        assert_eq!(
201            parse_manifest(&with_bom).map(|m| m.installer),
202            Some("install.ps1".to_string())
203        );
204        assert_eq!(parse_manifest("{not json"), None);
205        assert_eq!(
206            parse_manifest(r#"{"version":"0.9.0"}"#),
207            None,
208            "no installer"
209        );
210    }
211
212    #[test]
213    fn an_unknown_manifest_version_is_not_called_a_replacement() {
214        let info = describe("0.9.0", None, Some(m("install.sh", UNKNOWN_VERSION)));
215        assert_eq!(info.method, "install.sh");
216        assert_eq!(info.manifest_matches, None);
217    }
218
219    /// The #594 case: the binary an MCP config names, installed by
220    /// install.ps1, later replaced by hand.
221    #[test]
222    fn a_manifest_names_the_installer_and_a_replaced_binary_is_flagged() {
223        let bin = Some(Utf8PathBuf::from(
224            "C:/Users/u/AppData/Local/Programs/doiget/doiget.exe",
225        ));
226        let info = describe("0.8.12", bin.clone(), Some(m("install.ps1", "0.8.9")));
227        assert_eq!(info.method, "install.ps1");
228        assert_eq!(info.manifest_matches, Some(false));
229        assert!(info.update.contains("install.ps1"));
230        let info = describe("0.8.12", bin, Some(m("install.ps1", "0.8.12")));
231        assert_eq!(info.manifest_matches, Some(true));
232    }
233
234    #[test]
235    fn without_a_manifest_the_method_is_read_from_the_path() {
236        for (path, method) in [
237            (
238                "/home/u/.npm/_npx/abc/node_modules/doiget-cli-linux-x64/bin/doiget",
239                "npm",
240            ),
241            ("/home/u/.cargo/bin/doiget", "cargo"),
242            ("/opt/homebrew/Cellar/doiget/0.8.13/bin/doiget", "homebrew"),
243            ("/nix/store/abc-doiget-0.8.13/bin/doiget", "nix"),
244            (
245                "/Users/u/Library/Application Support/Claude/Claude Extensions/doiget/server/doiget",
246                "mcpb",
247            ),
248            ("/usr/local/bin/doiget", "unknown"),
249        ] {
250            let info = describe("0.9.0", Some(Utf8PathBuf::from(path)), None);
251            assert_eq!(info.method, method, "{path}");
252            assert_eq!(info.manifest_matches, None);
253        }
254        assert!(describe("0.9.0", None, None)
255            .check
256            .contains("doiget version --check"));
257    }
258}