galdr 0.16.0

Record & Replay for agent skills — capture a session's tool calls and distill them into a reproducible skill. Local-first.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
//! Self-update: check crates.io for a newer galdr and install it on request.
//!
//! galdr is local-first with zero background network. The **only** time it reaches
//! the network on its own initiative is when the user explicitly asks — `galdr
//! upgrade`, `galdr upgrade --check`, and the update line in `galdr doctor`. Every
//! one of those touches crates.io through a short-timeout `curl` shell-out and fails
//! soft: no connection is a *note*, never an error.
//!
//! We shell out to `curl --max-time 3` rather than pull in an HTTP client. The only
//! HTTP dependency in the tree (`reqwest`) is gated behind the optional `mlx` feature
//! and loopback-only by design, so a default build has no client at all — and adding
//! a TLS stack just to read one index line would be pure weight against the
//! local-first ethos. `curl` is present on every macOS and virtually every Linux; if
//! it is missing, that is simply treated as "offline".
//!
//! The crates.io *sparse* index serves one NDJSON line per published version at
//! `https://index.crates.io/ga/ld/galdr` (the `ga/ld/` shard is derived from the
//! crate name). We keep the greatest non-yanked semver and compare it against this
//! binary's compile-time `CARGO_PKG_VERSION`.

use std::cmp::Ordering;
use std::fmt;
use std::path::PathBuf;
use std::process::Command;

use anyhow::{Context, Result, anyhow, bail};
use serde::Deserialize;

use crate::{daemon, ipc, launchd};

/// The crates.io sparse-index URL for galdr. One JSON line per published version.
const INDEX_URL: &str = "https://index.crates.io/ga/ld/galdr";

/// How long `curl` may spend on the whole transfer before it is treated as offline.
/// Short on purpose: an update check must never make `doctor` feel slow.
const CURL_MAX_TIME: &str = "3";

/// A minimal semantic version: `major.minor.patch` with an optional pre-release tag.
///
/// crates.io release versions are plain `x.y.z`, but we parse (and order) a
/// pre-release suffix too so a `-rc` build never sorts above its own release. Build
/// metadata (`+…`) is stripped: semver says it does not affect precedence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SemVer {
    major: u64,
    minor: u64,
    patch: u64,
    pre: Option<String>,
}

impl SemVer {
    /// Parses `major.minor.patch[-pre][+build]`. Returns `None` for anything that is
    /// not three dotted integers, so a stray or malformed index line is simply
    /// ignored rather than crashing the check.
    pub fn parse(raw: &str) -> Option<Self> {
        let raw = raw.trim();
        // Peel off pre-release (`-`) first, then build metadata (`+`) from either part.
        let (core, pre) = match raw.split_once('-') {
            Some((core, rest)) => {
                let pre = rest.split('+').next().unwrap_or(rest);
                if pre.is_empty() {
                    return None;
                }
                (core, Some(pre.to_string()))
            }
            None => (raw.split('+').next().unwrap_or(raw), None),
        };
        let mut parts = core.split('.');
        let major = parts.next()?.parse().ok()?;
        let minor = parts.next()?.parse().ok()?;
        let patch = parts.next()?.parse().ok()?;
        if parts.next().is_some() {
            return None; // more than three components is not a version we understand.
        }
        Some(Self {
            major,
            minor,
            patch,
            pre,
        })
    }
}

impl Ord for SemVer {
    fn cmp(&self, other: &Self) -> Ordering {
        (self.major, self.minor, self.patch)
            .cmp(&(other.major, other.minor, other.patch))
            .then_with(|| match (&self.pre, &other.pre) {
                // A release outranks any pre-release of the same core version.
                (None, None) => Ordering::Equal,
                (None, Some(_)) => Ordering::Greater,
                (Some(_), None) => Ordering::Less,
                // Both pre-release: a lexical compare is enough for galdr's scheme.
                (Some(a), Some(b)) => a.cmp(b),
            })
    }
}

impl PartialOrd for SemVer {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl fmt::Display for SemVer {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
        if let Some(pre) = &self.pre {
            write!(f, "-{pre}")?;
        }
        Ok(())
    }
}

/// One line of the crates.io sparse index. We only need the version and its yanked
/// flag; every other field (deps, checksum, features) is ignored.
#[derive(Deserialize)]
struct IndexEntry {
    vers: String,
    #[serde(default)]
    yanked: bool,
}

/// The greatest non-yanked version in a crates.io sparse-index body. Tolerates stray
/// or unparseable lines (a captive portal that returns HTML yields *no* usable
/// version, which surfaces as an error the caller can treat as "skip").
pub fn parse_index(raw: &str) -> Result<SemVer> {
    let mut best: Option<SemVer> = None;
    for line in raw.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let Ok(entry) = serde_json::from_str::<IndexEntry>(line) else {
            continue;
        };
        if entry.yanked {
            continue;
        }
        let Some(version) = SemVer::parse(&entry.vers) else {
            continue;
        };
        if best.as_ref().is_none_or(|current| version > *current) {
            best = Some(version);
        }
    }
    best.context("crates.io index carried no usable galdr version")
}

/// This binary's own version, from the compile-time `CARGO_PKG_VERSION`. Infallible:
/// Cargo guarantees a valid semver here.
fn current_version() -> SemVer {
    SemVer::parse(env!("CARGO_PKG_VERSION")).expect("galdr's own version is valid semver")
}

/// Fetches the raw sparse-index body, or `None` when the network cannot be reached.
///
/// `GALDR_INDEX_FILE` (read a local file instead of the network) and
/// `GALDR_INDEX_URL` (override the crates.io URL) are test/debug affordances in the
/// same spirit as `GALDR_ROOT`: they keep the update path hermetic and let an
/// offline case be simulated deterministically. A missing/unreadable file, a `curl`
/// failure, a timeout, or a missing `curl` binary all collapse to `None` — offline.
fn fetch_index() -> Option<String> {
    if let Some(file) = std::env::var_os("GALDR_INDEX_FILE") {
        // A missing fixture is exactly the "offline" signal tests want.
        return std::fs::read_to_string(file).ok();
    }
    let url = std::env::var("GALDR_INDEX_URL").unwrap_or_else(|_| INDEX_URL.to_string());
    curl_get(&url)
}

/// A single, short-timeout GET via `curl`. Any failure — spawn error (no curl),
/// non-zero exit (network down, HTTP error under `-f`), or non-UTF-8 body — is
/// `None`, i.e. offline.
fn curl_get(url: &str) -> Option<String> {
    let output = Command::new("curl")
        .args(["--max-time", CURL_MAX_TIME, "-sfL", url])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    String::from_utf8(output.stdout).ok()
}

/// The outcome of comparing the installed galdr against the crates.io index.
#[derive(Debug, PartialEq, Eq)]
pub enum LatestCheck {
    /// The installed version matches the newest published one.
    UpToDate { current: SemVer },
    /// A newer version is published.
    Newer { current: SemVer, latest: SemVer },
    /// The local build is newer than anything on crates.io — the normal state when
    /// running from a clone whose version bump has not been published yet.
    LocalAhead { current: SemVer, latest: SemVer },
    /// The index could not be reached (or read). Never an error, by design.
    Offline,
}

/// Compares this binary against the newest published galdr. Offline is a variant, not
/// an error; a genuinely malformed index (reachable but not parseable) is the only
/// error case, which callers may still choose to treat softly.
pub fn check_latest() -> Result<LatestCheck> {
    let current = current_version();
    let Some(raw) = fetch_index() else {
        return Ok(LatestCheck::Offline);
    };
    let latest = parse_index(&raw)?;
    Ok(match latest.cmp(&current) {
        Ordering::Equal => LatestCheck::UpToDate { current },
        Ordering::Greater => LatestCheck::Newer { current, latest },
        Ordering::Less => LatestCheck::LocalAhead { current, latest },
    })
}

/// Where `galdr upgrade` installs from.
#[derive(Debug, PartialEq, Eq)]
pub enum InstallSource {
    /// `cargo install galdr` — the published crate (default).
    Crates,
    /// `cargo install --path <dir>` — a local clone, the operator's usual path.
    Path(PathBuf),
}

impl InstallSource {
    /// Interprets the `--from` values: absent or `crates` → crates.io; `path <dir>` →
    /// a local clone. Anything else is a usage error naming the exact accepted forms.
    pub fn parse(from: Option<Vec<String>>) -> Result<Self> {
        let Some(values) = from else {
            return Ok(Self::Crates);
        };
        match values.as_slice() {
            [kind] if kind == "crates" => Ok(Self::Crates),
            [kind, dir] if kind == "path" => Ok(Self::Path(PathBuf::from(dir))),
            [kind] if kind == "path" => {
                bail!("`--from path` needs a directory: `galdr upgrade --from path <dir>`")
            }
            _ => bail!("invalid --from; use `--from crates` (default) or `--from path <dir>`"),
        }
    }
}

/// Entry point for `galdr upgrade [--check] [--from …]`. Returns the process exit
/// code: `0` for up to date / local-ahead / offline / a successful install, `10` for
/// `--check` when a newer version exists (a distinct, script-friendly signal). A
/// genuine failure (bad `--from`, missing `cargo`, a failed install) is an `Err`,
/// which the caller maps to exit `1`.
pub fn run(check: bool, from: Option<Vec<String>>) -> Result<i32> {
    // Validate the source up front so a bad `--from` fails fast, before any network.
    let source = InstallSource::parse(from)?;

    match check_latest()? {
        LatestCheck::Offline => {
            println!("update check skipped (offline; could not reach crates.io)");
            Ok(0)
        }
        LatestCheck::UpToDate { current } => {
            println!("galdr {current} is up to date");
            Ok(0)
        }
        LatestCheck::LocalAhead { current, latest } => {
            println!("local build v{current} ahead of crates.io (v{latest})");
            Ok(0)
        }
        LatestCheck::Newer { current, latest } => {
            if check {
                println!("galdr {latest} available (you have {current}) — run galdr upgrade");
                return Ok(10);
            }
            println!("galdr {current}{latest}: upgrading via cargo install…");
            install(&source)?;
            println!("galdr upgraded to {latest}");
            restart_daemon_if_stale(&latest);
            Ok(0)
        }
    }
}

/// Runs the actual `cargo install`, inheriting stdio so the build streams live. A
/// missing `cargo` is reported with an actionable message rather than a raw OS error.
fn install(source: &InstallSource) -> Result<()> {
    let mut cmd = Command::new("cargo");
    cmd.arg("install");
    match source {
        InstallSource::Crates => {
            cmd.args(["galdr", "--locked", "--force"]);
        }
        InstallSource::Path(dir) => {
            cmd.arg("--path").arg(dir).args(["--locked", "--force"]);
        }
    }
    let status = cmd.status().map_err(|e| {
        if e.kind() == std::io::ErrorKind::NotFound {
            anyhow!(
                "cargo not found on PATH; install Rust (https://rustup.rs), then re-run `galdr upgrade`"
            )
        } else {
            anyhow!("could not run cargo install: {e}")
        }
    })?;
    if !status.success() {
        bail!(
            "cargo install failed (exit {})",
            status
                .code()
                .map_or_else(|| "signal".to_string(), |c| c.to_string())
        );
    }
    Ok(())
}

/// After a successful install the on-disk binary is new but any running daemon still
/// serves the old one over the control socket — the exact skew `galdr doctor` warns
/// about. If a daemon is running on a different (or unknown) version, restart it the
/// way the operator does: stop it, then relaunch detached. A running daemon that
/// already reports the new version is left alone.
fn restart_daemon_if_stale(latest: &SemVer) {
    let Ok(ipc::Response::Pong { version }) = ipc::query(&ipc::Request::Ping) else {
        // No daemon answering: nothing to restart.
        return;
    };
    let already_current = version
        .as_deref()
        .and_then(SemVer::parse)
        .is_some_and(|running| running == *latest);
    if already_current {
        println!("daemon already running {latest}; no restart needed");
        return;
    }
    let was = version.as_deref().unwrap_or("unknown");
    // A launchd-managed daemon is restarted in place (kickstart -k re-launches it from
    // the on-disk binary); a loose nohup daemon is stopped and relaunched detached.
    if launchd::is_managed() {
        println!("restarting launchd-managed daemon (was {was}) so it runs {latest}");
        if let Err(e) = launchd::kickstart() {
            eprintln!(
                "warning: could not kickstart the daemon: {e:#}\n\
                 restart it yourself: launchctl kickstart -k gui/$(id -u)/dev.galdr.daemon"
            );
        }
    } else {
        println!("restarting daemon (was {was}) so it runs {latest}");
        if let Err(e) = restart_daemon() {
            eprintln!(
                "warning: could not restart the daemon: {e:#}\n\
                 restart it yourself: galdr daemon stop && galdr daemon"
            );
        }
    }
}

/// Stops the running daemon and relaunches it detached. Reuses the daemon's own
/// detach path (own process group, null stdio) — the same thing `galdr daemon
/// --detach` does — so the freshly installed binary now on disk becomes the daemon.
fn restart_daemon() -> Result<()> {
    // Stop the old daemon and wait for it to release the socket — otherwise the new
    // daemon's single-instance probe would see the old one and bow out.
    daemon::stop_and_wait();
    daemon::run(true)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn semver_parses_the_common_shapes() {
        let v = SemVer::parse("0.15.0").unwrap();
        assert_eq!((v.major, v.minor, v.patch), (0, 15, 0));
        assert!(v.pre.is_none());
        // Build metadata is stripped; a pre-release tag is retained.
        assert_eq!(
            SemVer::parse("1.2.3+abc").unwrap(),
            SemVer::parse("1.2.3").unwrap()
        );
        assert_eq!(
            SemVer::parse("1.2.3-rc.1+build").unwrap().pre.as_deref(),
            Some("rc.1")
        );
        // Garbage and wrong arity are rejected, not panicked on.
        assert!(SemVer::parse("not.a.version").is_none());
        assert!(SemVer::parse("1.2").is_none());
        assert!(SemVer::parse("1.2.3.4").is_none());
        assert!(SemVer::parse("1.2.3-").is_none());
    }

    #[test]
    fn semver_orders_including_local_ahead_and_prerelease() {
        let older = SemVer::parse("0.14.2").unwrap();
        let current = SemVer::parse("0.15.0").unwrap();
        let newer = SemVer::parse("0.15.1").unwrap();
        // The published index is behind a local build: local is greater (local-ahead).
        assert!(current > older);
        // A real update: the index is ahead.
        assert!(newer > current);
        // Equality is reflexive across a fresh parse.
        assert_eq!(current, SemVer::parse("0.15.0").unwrap());
        // Cross-component ordering, not lexical string ordering (0.9.0 < 0.10.0).
        assert!(SemVer::parse("0.10.0").unwrap() > SemVer::parse("0.9.0").unwrap());
        // A release outranks its own pre-release.
        assert!(current > SemVer::parse("0.15.0-rc.1").unwrap());
    }

    #[test]
    fn parse_index_picks_the_greatest_non_yanked_version() {
        // A newer-but-yanked release must not win; the greatest live version does,
        // regardless of line order. A malformed line is tolerated.
        let raw = concat!(
            r#"{"name":"galdr","vers":"0.14.0","deps":[],"cksum":"a","features":{},"yanked":false}"#,
            "\n",
            r#"{"name":"galdr","vers":"0.15.0","deps":[],"cksum":"b","features":{},"yanked":false}"#,
            "\n",
            r#"{"name":"galdr","vers":"0.16.0","deps":[],"cksum":"c","features":{},"yanked":true}"#,
            "\n",
            "not json at all\n",
            r#"{"name":"galdr","vers":"0.14.2","deps":[],"cksum":"d","features":{},"yanked":false}"#,
            "\n",
        );
        assert_eq!(parse_index(raw).unwrap(), SemVer::parse("0.15.0").unwrap());
    }

    #[test]
    fn parse_index_errors_when_nothing_usable() {
        // A reachable-but-garbage body (e.g. a captive portal) yields no version.
        assert!(parse_index("<html>hi</html>\n").is_err());
        // Every version yanked → nothing installable.
        assert!(parse_index(r#"{"vers":"1.0.0","yanked":true}"#).is_err());
    }

    #[test]
    fn install_source_parses_from_flag_values() {
        assert_eq!(InstallSource::parse(None).unwrap(), InstallSource::Crates);
        assert_eq!(
            InstallSource::parse(Some(vec!["crates".into()])).unwrap(),
            InstallSource::Crates
        );
        assert_eq!(
            InstallSource::parse(Some(vec!["path".into(), "/tmp/galdr".into()])).unwrap(),
            InstallSource::Path(PathBuf::from("/tmp/galdr"))
        );
        // `path` with no directory, and an unknown source, are usage errors.
        assert!(InstallSource::parse(Some(vec!["path".into()])).is_err());
        assert!(InstallSource::parse(Some(vec!["bogus".into()])).is_err());
    }
}