bitrouter 1.0.0-alpha.26

BitRouter: an LLM API router. CLI + TUI + assembly library.
Documentation
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
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
//! `bitrouter update` — in-place self-updater built on cargo-dist's
//! `axoupdater`. Decision logic (install-method detection, release channel,
//! nudge-cache TTL) lives in small pure functions so it can be unit-tested
//! without touching the network or replacing the running binary.

use crate::output::reports::update::UpdateReport;
use crate::{daemon, style};
use anyhow::Result;
use axoupdater::{AxoUpdater, UpdateRequest};
use serde::{Deserialize, Serialize};
use std::path::Path;

/// How the running `bitrouter` binary was installed, used to pick the right
/// upgrade path when there is no cargo-dist receipt to self-update from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InstallMethod {
    Homebrew,
    Cargo,
    Npm,
    Unknown,
}

/// True when `exe` is the binary the cargo-dist npm wrapper unpacks and execs.
/// The wrapper extracts the platform binary to `<pkg>/node_modules/.bin_real/`
/// and spawns it directly, so a process launched via `npm`/`npx` reports that
/// path from `current_exe()`. Matched on path components so it holds on both
/// `/`- and `\`-separated platforms.
fn is_npm_install(exe: &Path) -> bool {
    fn normal(c: Option<std::path::Component<'_>>) -> Option<&str> {
        match c {
            Some(std::path::Component::Normal(s)) => s.to_str(),
            _ => None,
        }
    }
    // .../ node_modules / .bin_real / <binary>
    let mut rev = exe.components().rev();
    rev.next(); // the binary file itself
    normal(rev.next()) == Some(".bin_real") && normal(rev.next()) == Some("node_modules")
}

/// Best-effort classification from the executable path and the resolved Cargo
/// home. npm installs run from `.../node_modules/.bin_real/...`; Homebrew
/// installs live under a `Cellar/bitrouter/...` path; `cargo install` places the
/// binary in `<CARGO_HOME>/bin`.
fn detect_install_method(exe: &Path, cargo_home: Option<&Path>) -> InstallMethod {
    // npm is checked first, and deliberately before Homebrew: a global npm
    // install under a Homebrew-managed Node lives beneath `/opt/homebrew/...`,
    // so the `/homebrew/` substring below would otherwise misclassify it.
    if is_npm_install(exe) {
        return InstallMethod::Npm;
    }
    let exe_str = exe.to_string_lossy();
    if exe_str.contains("/Cellar/bitrouter/") || exe_str.contains("/homebrew/") {
        return InstallMethod::Homebrew;
    }
    if cargo_home.is_some_and(|home| exe.starts_with(home.join("bin"))) {
        return InstallMethod::Cargo;
    }
    InstallMethod::Unknown
}

/// Stable lowercase label for the JSON `install_method` field.
fn method_label(method: InstallMethod) -> &'static str {
    match method {
        InstallMethod::Homebrew => "homebrew",
        InstallMethod::Cargo => "cargo",
        InstallMethod::Npm => "npm",
        InstallMethod::Unknown => "unknown",
    }
}

/// The exact command a user should run to upgrade a package-manager-managed
/// install. Unknown installs get the generic re-run-the-installer hint.
fn delegation_command(method: InstallMethod) -> String {
    match method {
        InstallMethod::Homebrew => "brew upgrade bitrouter".to_string(),
        InstallMethod::Cargo => "cargo install bitrouter --force".to_string(),
        // CI publishes prereleases to npm under the default `latest` tag; switch
        // to `@next` here if a dedicated prerelease dist-tag is ever introduced.
        InstallMethod::Npm => "npm i -g bitrouter@latest".to_string(),
        InstallMethod::Unknown => {
            "curl --proto '=https' --tlsv1.2 -LsSf \
             https://github.com/bitrouter/bitrouter/releases/latest/download/bitrouter-installer.sh | sh"
                .to_string()
        }
    }
}

/// Resolved release target, decoupled from axoupdater's own request type so the
/// decision is unit-testable. Converted to an `axoupdater::UpdateRequest` by
/// `to_request`.
#[derive(Debug, Clone, PartialEq, Eq)]
enum VersionSpec {
    /// Newest stable (non-prerelease) release.
    Latest,
    /// Newest release including prereleases — the default while pre-1.0.
    LatestPrerelease,
    /// A specific pinned tag (enables downgrade/rollback).
    Tag(String),
}

/// `--tag` always wins; otherwise `--stable` selects stable-only, and the
/// default follows prereleases (the project currently ships only `alpha.*`).
fn choose_spec(tag: Option<&str>, stable: bool) -> VersionSpec {
    match tag {
        Some(t) => VersionSpec::Tag(t.to_string()),
        None if stable => VersionSpec::Latest,
        None => VersionSpec::LatestPrerelease,
    }
}

/// Options parsed from the `bitrouter update` flags.
#[derive(Debug)]
pub struct UpdateOptions {
    pub check: bool,
    pub tag: Option<String>,
    pub stable: bool,
    pub restart: bool,
    pub yes: bool,
}

/// The result of `run`: the report to emit through the [`Output`] driver, plus
/// whether the dispatch layer must still restart a running daemon onto the new
/// binary before emitting.
///
/// [`Output`]: crate::output::Output
#[derive(Debug)]
pub struct RunOutcome {
    /// The single result value for `bitrouter update`, rendered to stdout.
    pub report: UpdateReport,
    /// A running daemon needs restarting to pick up the new binary. When true,
    /// `report.daemon` is already set to `restarted` on the optimistic
    /// assumption the restart succeeds (a failure surfaces as the error
    /// envelope instead, replacing the whole result).
    pub restart_needed: bool,
}

impl RunOutcome {
    /// A terminal outcome that needs no daemon restart.
    fn done(report: UpdateReport) -> Self {
        Self {
            report,
            restart_needed: false,
        }
    }
}

/// Current binary version, baked in at compile time.
fn current_version() -> &'static str {
    env!("CARGO_PKG_VERSION")
}

/// Resolve `$CARGO_HOME`, falling back to `~/.cargo`, for install detection.
fn cargo_home() -> Option<std::path::PathBuf> {
    if let Some(h) = std::env::var_os("CARGO_HOME") {
        return Some(std::path::PathBuf::from(h));
    }
    std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".cargo"))
}

/// Translate our channel decision into axoupdater's request type.
fn to_request(spec: VersionSpec) -> UpdateRequest {
    match spec {
        VersionSpec::Latest => UpdateRequest::Latest,
        VersionSpec::LatestPrerelease => UpdateRequest::LatestMaybePrerelease,
        VersionSpec::Tag(t) => UpdateRequest::SpecificTag(t),
    }
}

/// Configure an axoupdater for the `bitrouter` app. Prefers the dist install
/// receipt; if absent, returns `None` so the caller can delegate.
fn build_updater() -> Option<AxoUpdater> {
    let mut updater = AxoUpdater::new_for("bitrouter");
    if let Err(e) = updater.load_receipt() {
        tracing::debug!(error = %e, "no install receipt; delegating to package manager");
        return None;
    }
    Some(updater)
}

pub async fn run(opts: UpdateOptions, socket: &Path) -> Result<RunOutcome> {
    let current = current_version().to_string();

    // 1. No receipt -> package-manager install; delegate, never clobber.
    let Some(mut updater) = build_updater() else {
        let exe = std::env::current_exe().unwrap_or_default();
        let method = detect_install_method(&exe, cargo_home().as_deref());
        return Ok(RunOutcome::done(UpdateReport::delegated(
            current,
            method_label(method),
            delegation_command(method),
        )));
    };

    // 2. Channel / pin.
    let spec = choose_spec(opts.tag.as_deref(), opts.stable);
    let target_label = match &spec {
        VersionSpec::Tag(t) => format!("version {t}"),
        _ => "the latest release".to_string(),
    };
    if let VersionSpec::Tag(_) = spec {
        updater.always_update(true);
    }
    updater.configure_version_specifier(to_request(spec));

    // 3. Dry run.
    if opts.check {
        let available = updater.is_update_needed().await?;
        let target = if available {
            updater.query_new_version().await?.map(|v| v.to_string())
        } else {
            None
        };
        return Ok(RunOutcome::done(UpdateReport::checked(
            current, available, target,
        )));
    }

    // 4. Confirm + swap. The prompt is interactive UI, not the command result,
    // so it goes to stderr — stdout carries only the final report.
    if !opts.yes && !confirm(&current, &target_label)? {
        return Ok(RunOutcome::done(UpdateReport::aborted(current)));
    }
    let Some(result) = updater.run().await? else {
        return Ok(RunOutcome::done(UpdateReport::unchanged(current)));
    };
    let new_version = result.new_version.to_string();

    // 5. Daemon awareness. The dispatch layer performs the restart (when asked)
    // before emitting; we label the report optimistically here.
    let daemon_running = daemon::endpoint_in_use(socket);
    let (daemon, restart_needed) = match (daemon_running, opts.restart) {
        (true, true) => (Some("restarted"), true),
        (true, false) => (Some("restart_needed"), false),
        (false, _) => (None, false),
    };
    Ok(RunOutcome {
        report: UpdateReport::updated(current, new_version, daemon),
        restart_needed,
    })
}

/// Interactive y/N confirmation. Defaults to no on empty input or non-tty EOF.
/// The prompt is written to stderr so stdout stays a clean JSON result.
fn confirm(current: &str, target: &str) -> Result<bool> {
    use std::io::Write;
    let p = style::Palette::for_stderr();
    eprint!(
        "Update bitrouter from {bold}{current}{reset} to {target}? [y/N] ",
        bold = p.bold,
        reset = p.reset,
    );
    std::io::stderr().flush()?;
    let mut line = String::new();
    if std::io::stdin().read_line(&mut line)? == 0 {
        return Ok(false);
    }
    Ok(matches!(
        line.trim().to_ascii_lowercase().as_str(),
        "y" | "yes"
    ))
}

/// Filename of the persisted update-check cache inside the bitrouter home.
const CACHE_FILENAME: &str = "update-check.json";

/// Persisted record of the last passive update check.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct UpdateCache {
    /// Unix seconds of the last network check.
    checked_at: i64,
    /// Latest version string seen at that check, if any.
    latest: Option<String>,
}

/// Whether the passive nudge should perform a fresh network check now. True
/// when never checked, or when more than `ttl_secs` have elapsed.
fn should_check(now: i64, last_checked: Option<i64>, ttl_secs: i64) -> bool {
    match last_checked {
        None => true,
        Some(prev) => now - prev >= ttl_secs,
    }
}

/// Read the cache from `<home>/update-check.json`. Any error (missing,
/// malformed) is treated as "no cache" — the nudge is best-effort.
fn read_cache(home: &Path) -> Option<UpdateCache> {
    let bytes = std::fs::read(home.join(CACHE_FILENAME)).ok()?;
    serde_json::from_slice(&bytes).ok()
}

/// Persist the cache to `<home>/update-check.json`, creating the home if needed.
fn write_cache(home: &Path, cache: &UpdateCache) -> Result<()> {
    crate::paths::ensure_home_directory(home)?;
    let bytes = serde_json::to_vec(cache)?;
    std::fs::write(home.join(CACHE_FILENAME), bytes)?;
    Ok(())
}

/// Env var that disables the passive update nudge entirely.
const NUDGE_DISABLE_ENV: &str = "BITROUTER_NO_UPDATE_CHECK";
/// Nudge network check cadence: once per day.
const NUDGE_TTL_SECS: i64 = 24 * 60 * 60;

/// Best-effort "update available" line for `bitrouter status`. Never returns an
/// error and never blocks meaningfully: it respects the opt-out env var, checks
/// the network at most once per `NUDGE_TTL_SECS` (cached under `home`), and
/// swallows every failure.
pub async fn maybe_nudge(home: &Path, p: &style::Palette) {
    if std::env::var_os(NUDGE_DISABLE_ENV).is_some() {
        return;
    }
    let now = chrono::Utc::now().timestamp();
    let cache = read_cache(home);
    let last = cache.as_ref().map(|c| c.checked_at);

    let latest: Option<String> = if should_check(now, last, NUDGE_TTL_SECS) {
        match query_latest().await {
            Some(v) => {
                let _ = write_cache(
                    home,
                    &UpdateCache {
                        checked_at: now,
                        latest: Some(v.clone()),
                    },
                );
                Some(v)
            }
            None => {
                // Record the attempt so we don't hammer the network on failure.
                let _ = write_cache(
                    home,
                    &UpdateCache {
                        checked_at: now,
                        latest: None,
                    },
                );
                cache.and_then(|c| c.latest)
            }
        }
    } else {
        cache.and_then(|c| c.latest)
    };

    if let Some(latest) = latest.filter(|l| is_newer(l, current_version())) {
        // Diagnostic, not the command result — goes to stderr so it never
        // pollutes a command's JSON stdout (e.g. `bitrouter status`).
        eprintln!();
        eprintln!(
            "  {dim}↑ {latest} available — run `bitrouter update`{reset}",
            dim = p.dim,
            reset = p.reset,
        );
    }
}

/// Query the newest version tag, prereleases included, with a short timeout.
/// Returns `None` on any error or timeout — the nudge is strictly best-effort.
async fn query_latest() -> Option<String> {
    let mut updater = build_updater()?;
    updater.configure_version_specifier(UpdateRequest::LatestMaybePrerelease);
    tokio::time::timeout(
        std::time::Duration::from_secs(3),
        updater.query_new_version(),
    )
    .await
    .ok()
    .and_then(|r| r.ok())
    .flatten()
    .map(|v| v.to_string())
}

/// True when `candidate` is a strictly greater semver than `current`. Parse
/// failures yield `false` so a malformed tag never nags the user.
fn is_newer(candidate: &str, current: &str) -> bool {
    match (
        semver::Version::parse(candidate),
        semver::Version::parse(current),
    ) {
        (Ok(c), Ok(cur)) => c > cur,
        _ => false,
    }
}

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

    #[test]
    fn detects_homebrew_from_cellar_path() {
        let exe = Path::new("/opt/homebrew/Cellar/bitrouter/1.0.0/bin/bitrouter");
        assert_eq!(detect_install_method(exe, None), InstallMethod::Homebrew);
    }

    #[test]
    fn detects_cargo_from_cargo_home_bin() {
        let exe = Path::new("/home/me/.cargo/bin/bitrouter");
        let cargo_home = Path::new("/home/me/.cargo");
        assert_eq!(
            detect_install_method(exe, Some(cargo_home)),
            InstallMethod::Cargo
        );
    }

    #[test]
    fn detects_npm_from_bin_real_path() {
        let exe =
            Path::new("/usr/local/lib/node_modules/bitrouter/node_modules/.bin_real/bitrouter");
        assert_eq!(detect_install_method(exe, None), InstallMethod::Npm);
    }

    #[test]
    fn npm_under_homebrew_node_is_npm_not_homebrew() {
        // A global npm install under a Homebrew-managed Node contains
        // `/homebrew/`; npm detection must win over the Homebrew heuristic.
        let exe =
            Path::new("/opt/homebrew/lib/node_modules/bitrouter/node_modules/.bin_real/bitrouter");
        assert_eq!(detect_install_method(exe, None), InstallMethod::Npm);
    }

    #[test]
    fn unknown_when_no_signal_matches() {
        let exe = Path::new("/usr/local/bin/bitrouter");
        assert_eq!(detect_install_method(exe, None), InstallMethod::Unknown);
    }

    #[test]
    fn delegation_command_is_method_specific() {
        assert_eq!(
            delegation_command(InstallMethod::Homebrew),
            "brew upgrade bitrouter"
        );
        assert_eq!(
            delegation_command(InstallMethod::Cargo),
            "cargo install bitrouter --force"
        );
        assert_eq!(
            delegation_command(InstallMethod::Npm),
            "npm i -g bitrouter@latest"
        );
        assert!(delegation_command(InstallMethod::Unknown).contains("bitrouter-installer.sh"));
    }

    #[test]
    fn tag_takes_precedence_over_channel() {
        assert_eq!(
            choose_spec(Some("1.0.0-alpha.18"), true),
            VersionSpec::Tag("1.0.0-alpha.18".to_string())
        );
    }

    #[test]
    fn default_channel_includes_prereleases() {
        assert_eq!(choose_spec(None, false), VersionSpec::LatestPrerelease);
    }

    #[test]
    fn stable_flag_excludes_prereleases() {
        assert_eq!(choose_spec(None, true), VersionSpec::Latest);
    }

    const DAY: i64 = 24 * 60 * 60;

    #[test]
    fn checks_when_never_checked_before() {
        assert!(should_check(1_000_000, None, DAY));
    }

    #[test]
    fn skips_when_within_ttl() {
        assert!(!should_check(1_000_000 + 100, Some(1_000_000), DAY));
    }

    #[test]
    fn checks_again_after_ttl_elapsed() {
        assert!(should_check(1_000_000 + DAY + 1, Some(1_000_000), DAY));
    }

    #[test]
    fn is_newer_compares_prerelease_semver() {
        assert!(is_newer("1.0.0-alpha.20", "1.0.0-alpha.19"));
        assert!(!is_newer("1.0.0-alpha.19", "1.0.0-alpha.19"));
        assert!(!is_newer("1.0.0-alpha.18", "1.0.0-alpha.19"));
        assert!(!is_newer("garbage", "1.0.0-alpha.19"));
    }

    #[test]
    fn cache_round_trips_through_disk() {
        let dir = std::env::temp_dir().join(format!("brupd-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let cache = UpdateCache {
            checked_at: 42,
            latest: Some("1.0.0-alpha.20".to_string()),
        };
        write_cache(&dir, &cache).unwrap();
        let read = read_cache(&dir).expect("cache present");
        assert_eq!(read.checked_at, 42);
        assert_eq!(read.latest.as_deref(), Some("1.0.0-alpha.20"));
        std::fs::remove_dir_all(&dir).ok();
    }
}