Skip to main content

bynk/
doctor.rs

1//! `bynk doctor` — the capability model, the checks, and the exit-code
2//! contract.
3//!
4//! Probes are **grouped by the capability they unlock**, not listed flat, so a
5//! compile-only user is never told they are "unhealthy" for lacking `wrangler`.
6//! The exit-code contract turns on *what an invocation asks about* (ADR: the
7//! doctor output / exit-code contract):
8//!
9//! - **Bare `bynk doctor`** is informational. It surveys everything but treats
10//!   only the *compile floor* (`bynkc` resolvable and not majorly skewed) as
11//!   required, so it exits `0` even with `test`/`dev` unavailable.
12//! - **`--only <capability>`** promotes that capability's tools to required.
13//! - **`--strict`** promotes *all* warnings (optional gaps, `npx`
14//!   provisionability, minor skew) to failures, for an all-green CI gate.
15
16use std::path::PathBuf;
17
18use crate::compiler::{Compiler, Origin, Skew};
19use crate::probe::{self, DetectOpts, Probe, Provenance, Toolbox};
20
21/// A unit of work a user might want to do, and the tools it needs.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Capability {
24    /// `bynkc` compile / check / fmt. Always satisfiable if `bynkc` resolved;
25    /// also the home of the driver↔compiler skew check.
26    Compile,
27    /// `bynk test` — Node and one of `tsc`/`tsx` (the runner ladder).
28    Test,
29    /// `dev` / deploy to Cloudflare — Node and `wrangler`.
30    Deploy,
31    /// Editor support — `bynkc-lsp`. Optional; never a failure (except strict).
32    Editor,
33    /// Build Bynk from source — a Rust toolchain. Contributor-only; reported
34    /// only inside the Bynk repo.
35    BuildFromSource,
36}
37
38impl Capability {
39    pub fn token(self) -> &'static str {
40        match self {
41            Capability::Compile => "compile",
42            Capability::Test => "test",
43            Capability::Deploy => "deploy",
44            Capability::Editor => "editor",
45            Capability::BuildFromSource => "build",
46        }
47    }
48
49    /// Optional capabilities never fail a run on their own — they note, and
50    /// `--strict` escalates.
51    pub fn is_optional(self) -> bool {
52        matches!(self, Capability::Editor | Capability::BuildFromSource)
53    }
54}
55
56/// Health of a single row or a whole capability. Ordered: `Ok < Warn < Fail`.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
58pub enum Level {
59    Ok,
60    Warn,
61    Fail,
62}
63
64/// One rendered line under a capability: a tool (or an any-of group like
65/// `tsc | tsx`), its health, a human detail, and a remedy when it is not `Ok`.
66#[derive(Debug, Clone)]
67pub struct Row {
68    pub label: String,
69    pub level: Level,
70    pub detail: String,
71    pub remedy: Option<String>,
72}
73
74/// A capability and its rows, with the aggregated health.
75#[derive(Debug, Clone)]
76pub struct CapabilityReport {
77    pub capability: Capability,
78    pub optional: bool,
79    pub rows: Vec<Row>,
80    pub level: Level,
81}
82
83/// The whole `doctor` result.
84#[derive(Debug, Clone)]
85pub struct Report {
86    pub driver_version: String,
87    pub compiler: Compiler,
88    pub capabilities: Vec<CapabilityReport>,
89}
90
91/// User-facing knobs.
92#[derive(Debug, Clone, Default)]
93pub struct DoctorOptions {
94    /// Scope the gate to one capability (promotes its tools to required).
95    pub only: Option<Capability>,
96    /// Escalate every warning to a failure.
97    pub strict: bool,
98}
99
100/// Environment facts the caller supplies (real values in `main`, fixed values
101/// in tests).
102#[derive(Debug, Clone)]
103pub struct Context {
104    /// Discovered project root (`bynk.toml`), for project-local resolution.
105    pub project_root: Option<PathBuf>,
106    /// Whether to include the contributor `build` capability.
107    pub in_repo: bool,
108    /// Minimum supported Node major (single-sourced from `bynkc`).
109    pub node_floor: u32,
110}
111
112impl Report {
113    /// Should the process exit non-zero, given the options?
114    ///
115    /// Non-zero iff a *required* capability has a hard failure, or — under
116    /// `--strict` — any capability is less than `Ok`. The compile floor is
117    /// always required; `--only <cap>` adds that capability.
118    pub fn exit_nonzero(&self, opts: &DoctorOptions) -> bool {
119        for cap in &self.capabilities {
120            let required =
121                cap.capability == Capability::Compile || opts.only == Some(cap.capability);
122            if required && cap.level == Level::Fail {
123                return true;
124            }
125        }
126        if opts.strict && self.capabilities.iter().any(|c| c.level != Level::Ok) {
127            return true;
128        }
129        false
130    }
131
132    /// One-word overall summary for the human header.
133    pub fn is_all_ok(&self) -> bool {
134        self.capabilities.iter().all(|c| c.level == Level::Ok)
135    }
136}
137
138/// Run the checks against a toolbox and a resolved compiler.
139pub fn diagnose(
140    tb: &dyn Toolbox,
141    compiler: &Compiler,
142    ctx: &Context,
143    opts: &DoctorOptions,
144) -> Report {
145    let root = ctx.project_root.as_deref();
146    let mut capabilities = vec![compile_report(compiler)];
147
148    // Only build the capability the user scoped to (plus the always-on compile
149    // floor), so `--only test` doesn't probe Cloudflare. With no filter, build
150    // them all.
151    let want = |cap: Capability| opts.only.is_none() || opts.only == Some(cap);
152
153    if want(Capability::Test) {
154        let node = detect_node(tb, root, ctx.node_floor);
155        let runner = detect_runner(tb, root);
156        capabilities.push(capability(Capability::Test, vec![node, runner]));
157    }
158    if want(Capability::Deploy) {
159        let node = detect_node(tb, root, ctx.node_floor);
160        let wrangler = detect_wrangler(tb, root);
161        capabilities.push(capability(Capability::Deploy, vec![node, wrangler]));
162    }
163    if want(Capability::Editor) {
164        let lsp = detect_plain(
165            tb,
166            "bynkc-lsp",
167            "install bynkc-lsp (or download from releases)",
168        );
169        capabilities.push(capability(Capability::Editor, vec![lsp]));
170    }
171    if ctx.in_repo && want(Capability::BuildFromSource) {
172        let cargo = detect_plain(tb, "cargo", "install Rust via https://rustup.rs");
173        capabilities.push(capability(Capability::BuildFromSource, vec![cargo]));
174    }
175
176    Report {
177        driver_version: crate::DRIVER_VERSION.to_string(),
178        compiler: compiler.clone(),
179        capabilities,
180    }
181}
182
183/// Compile/check/fmt. The compiler is **linked in-process** (slice 7 / ADR 0101),
184/// so it is always available and cannot skew against itself — the always-ok row.
185/// The external-`bynkc` resolution + skew check applies **only** under a
186/// `BYNK_BYNKC` override (`Origin::Override`), the one path on which a second,
187/// skewable compiler enters; with no override there is nothing external to check
188/// (amends ADR 0084).
189fn compile_report(compiler: &Compiler) -> CapabilityReport {
190    let mut rows = vec![Row {
191        label: "compiler".into(),
192        level: Level::Ok,
193        detail: "in-process".into(),
194        remedy: None,
195    }];
196
197    // Only when the user explicitly pointed `bynk` at an external compiler does a
198    // second binary — and thus skew — exist. Report it then, and only then.
199    if matches!(compiler.origin, Some(Origin::Override)) {
200        let ver = compiler
201            .version
202            .map(|v| v.to_string())
203            .unwrap_or_else(|| "unknown".into());
204        let row = match (&compiler.path, compiler.skew) {
205            (None, _) => Row {
206                label: "bynkc (override)".into(),
207                level: Level::Fail,
208                detail: "$BYNK_BYNKC set but not found".into(),
209                remedy: Some("fix BYNK_BYNKC, or unset it to use the in-process compiler".into()),
210            },
211            (Some(_), Some(Skew::Major)) => Row {
212                label: "bynkc (override)".into(),
213                level: Level::Fail,
214                detail: format!("{ver} — major skew vs driver"),
215                remedy: Some("align the override bynkc with bynk, or unset BYNK_BYNKC".into()),
216            },
217            (Some(_), Some(Skew::Minor)) => Row {
218                label: "bynkc (override)".into(),
219                level: Level::Warn,
220                detail: format!("{ver} — minor skew vs driver"),
221                remedy: Some("align the override bynkc with bynk, or unset BYNK_BYNKC".into()),
222            },
223            // Resolved but its version could not be read — the binary may not
224            // be a bynkc at all; don't render it as a clean Ok.
225            (Some(_), None) => Row {
226                label: "bynkc (override)".into(),
227                level: Level::Warn,
228                detail: "resolved, but its version could not be read".into(),
229                remedy: Some("check that BYNK_BYNKC points at a bynkc binary".into()),
230            },
231            (Some(_), _) => Row {
232                label: "bynkc (override)".into(),
233                level: Level::Ok,
234                detail: format!("{ver} (override)"),
235                remedy: None,
236            },
237        };
238        rows.push(row);
239    }
240
241    let level = rows.iter().map(|r| r.level).max().unwrap_or(Level::Ok);
242    CapabilityReport {
243        capability: Capability::Compile,
244        optional: false,
245        rows,
246        level,
247    }
248}
249
250/// Aggregate a capability from its rows (worst row wins).
251fn capability(cap: Capability, rows: Vec<Row>) -> CapabilityReport {
252    let level = rows.iter().map(|r| r.level).max().unwrap_or(Level::Ok);
253    CapabilityReport {
254        capability: cap,
255        optional: cap.is_optional(),
256        rows,
257        level,
258    }
259}
260
261fn detect_node(tb: &dyn Toolbox, root: Option<&std::path::Path>, floor: u32) -> Row {
262    // A runtime is never npx-provisionable.
263    let probe = probe::detect(
264        tb,
265        "node",
266        DetectOpts {
267            project_root: root,
268            allow_npx: false,
269        },
270    );
271    let remedy = format!("install Node.js ≥ {floor} from https://nodejs.org");
272    if probe.is_missing() {
273        return Row {
274            label: "node".into(),
275            level: Level::Fail,
276            detail: "missing".into(),
277            remedy: Some(remedy),
278        };
279    }
280    let below = probe.version.map(|v| v.major < floor).unwrap_or(false);
281    if below {
282        let v = probe.version.unwrap();
283        return Row {
284            label: "node".into(),
285            level: Level::Warn,
286            detail: format!("v{v} below floor (≥ {floor})"),
287            remedy: Some(remedy),
288        };
289    }
290    Row {
291        label: "node".into(),
292        level: Level::Ok,
293        detail: present_detail(&probe),
294        remedy: None,
295    }
296}
297
298/// The `tsc | tsx` runner requirement — satisfied by the *better* of the two.
299fn detect_runner(tb: &dyn Toolbox, root: Option<&std::path::Path>) -> Row {
300    let tsc = probe::detect(
301        tb,
302        "tsc",
303        DetectOpts {
304            project_root: root,
305            allow_npx: true,
306        },
307    );
308    let tsx = probe::detect(
309        tb,
310        "tsx",
311        DetectOpts {
312            project_root: root,
313            allow_npx: true,
314        },
315    );
316    let best = pick_better(&tsc, &tsx);
317    let floor = bynk_emit::TYPESCRIPT_MAJOR_FLOOR;
318    let tested = bynk_emit::TYPESCRIPT_MAJOR_TESTED;
319    // #1672: lead with the TypeScript that is verified, and the one that
320    // type-checks; `tsx` only runs the emitted code.
321    let remedy = format!(
322        "npm install -g typescript@{tested} (or `npm install -g tsx`, which runs tests without type-checking)"
323    );
324    match best {
325        // #1672: a `tsc` below the verified floor is unsupported, a real defect
326        // in this environment, so it warns. One above the tested major is
327        // *reported* as untested but stays `ok`: it is a statement about this
328        // repo's verification coverage, not a fault the user has, and a
329        // warning would turn `doctor --strict` red for everyone the day a new
330        // TypeScript major ships, with advice to downgrade a likely-working
331        // toolchain.
332        Some(p) if p.is_present() && p.tool == "tsc" => {
333            let detail = format!("{} {}", p.tool, present_detail(p));
334            match p.version.map(|v| v.major) {
335                Some(major) if major < floor => Row {
336                    label: "tsc | tsx".into(),
337                    level: Level::Warn,
338                    detail: format!("{detail}, below floor (≥ {floor})"),
339                    remedy: Some(remedy),
340                },
341                Some(major) if major > tested => Row {
342                    label: "tsc | tsx".into(),
343                    level: Level::Ok,
344                    detail: format!("{detail}, untested (verified up to {tested})"),
345                    remedy: None,
346                },
347                _ => Row {
348                    label: "tsc | tsx".into(),
349                    level: Level::Ok,
350                    detail,
351                    remedy: None,
352                },
353            }
354        }
355        Some(p) if p.is_present() => Row {
356            label: "tsc | tsx".into(),
357            level: Level::Ok,
358            detail: format!("{} {}", p.tool, present_detail(p)),
359            remedy: None,
360        },
361        Some(p) => Row {
362            // provisionable via npx
363            label: "tsc | tsx".into(),
364            level: Level::Warn,
365            detail: format!("{} provisionable via npx (not installed)", p.tool),
366            remedy: Some(remedy),
367        },
368        None => Row {
369            label: "tsc | tsx".into(),
370            level: Level::Fail,
371            detail: "missing".into(),
372            remedy: Some(remedy),
373        },
374    }
375}
376
377/// #1732: an installed wrangler older than [`bynk_emit::WRANGLER_MIN`], whose
378/// `workerd` therefore refuses the pinned [`bynk_emit::COMPATIBILITY_DATE`].
379/// `false` for an npx-provisioned or unversioned wrangler, which can't be
380/// judged without running it. Shared by `doctor`'s row and `bynk dev`'s notice.
381pub fn wrangler_below_min(probe: &Probe) -> bool {
382    let min = probe::Version::parse(bynk_emit::WRANGLER_MIN).expect("WRANGLER_MIN is a version");
383    probe.is_present()
384        && probe
385            .version
386            .is_some_and(|v| (v.major, v.minor, v.patch) < (min.major, min.minor, min.patch))
387}
388
389/// #1732: how to upgrade a too-old wrangler, for where it came from. A
390/// project's own wrangler (which the driver prefers over `PATH`) is upgraded in
391/// the project, since a global install wouldn't change what runs. Shared by
392/// `doctor`'s row and `bynk dev`'s notice so the two can't disagree. No version
393/// in it: it shows in every `--format short` line and the goldens.
394pub fn wrangler_upgrade_remedy(probe: &Probe) -> &'static str {
395    match probe.provenance {
396        Provenance::ProjectLocal(_) => "npm install --save-dev wrangler@4 (in the project)",
397        _ => "npm install -g wrangler@4",
398    }
399}
400
401/// #1732: wrangler, checked against [`bynk_emit::WRANGLER_MIN`], the oldest
402/// whose `workerd` serves the [`bynk_emit::COMPATIBILITY_DATE`] every generated
403/// `wrangler.toml` pins. An older one's `workerd` refuses the date, so
404/// `bynk dev` fails. It also doesn't read the Durable Object `exports` map
405/// (#1796), so a project with an agent can't deploy on it, and `bynk deploy`
406/// refuses one that would push a class (`deploy::plan::wrangler_floor_refusal`).
407/// A project with no agent still deploys (Cloudflare accepts any past date).
408/// Doctor doesn't know which kind of project it is, so this stays a warning,
409/// not a failure, and `doctor --only deploy` doesn't go red on a toolchain
410/// that deploys an agent-free project. An npx-provisioned wrangler's version isn't known
411/// without running npx (which may download), so its row keeps the usual
412/// "provisionable" warning, and the remedy says to clear a stale npx cache: one
413/// older than the minimum fails the same way, because npx keys the cache on the
414/// spec `wrangler@4`, not the version.
415fn detect_wrangler(tb: &dyn Toolbox, root: Option<&std::path::Path>) -> Row {
416    let min = bynk_emit::WRANGLER_MIN;
417    let date = bynk_emit::COMPATIBILITY_DATE;
418    // No version or date in a remedy: it shows in every `--format short` line
419    // (and the goldens), which a compatibility-date review shouldn't churn. The
420    // below-minimum detail names both. Each remedy fits where wrangler came
421    // from: a project's own wrangler is upgraded in the project, and only an npx
422    // one can be a stale cache.
423    let install = "npm install -g wrangler@4";
424    let probe = probe::detect(
425        tb,
426        "wrangler",
427        DetectOpts {
428            project_root: root,
429            allow_npx: true,
430        },
431    );
432    if wrangler_below_min(&probe) {
433        let remedy = wrangler_upgrade_remedy(&probe);
434        return Row {
435            label: "wrangler".into(),
436            level: Level::Warn,
437            detail: format!(
438                "{}, below {min}: `bynk dev` can't serve compatibility date {date}, and agents can't deploy",
439                present_detail(&probe)
440            ),
441            remedy: Some(remedy.into()),
442        };
443    }
444    let mut row = npm_row("wrangler", &probe, install);
445    if probe.is_provisionable() {
446        row.remedy = Some(format!(
447            "{install}, or clear a stale npx cache (~/.npm/_npx)"
448        ));
449    }
450    row
451}
452
453fn detect_plain(tb: &dyn Toolbox, tool: &str, remedy: &str) -> Row {
454    let probe = probe::detect(
455        tb,
456        tool,
457        DetectOpts {
458            project_root: None,
459            allow_npx: false,
460        },
461    );
462    if probe.is_present() {
463        Row {
464            label: tool.into(),
465            level: Level::Ok,
466            detail: present_detail(&probe),
467            remedy: None,
468        }
469    } else {
470        Row {
471            label: tool.into(),
472            level: Level::Fail,
473            detail: "missing".into(),
474            remedy: Some(remedy.into()),
475        }
476    }
477}
478
479fn npm_row(tool: &str, probe: &Probe, remedy: &str) -> Row {
480    if probe.is_present() {
481        Row {
482            label: tool.into(),
483            level: Level::Ok,
484            detail: present_detail(probe),
485            remedy: None,
486        }
487    } else if probe.is_provisionable() {
488        Row {
489            label: tool.into(),
490            level: Level::Warn,
491            detail: "provisionable via npx (not installed)".into(),
492            remedy: Some(remedy.into()),
493        }
494    } else {
495        Row {
496            label: tool.into(),
497            level: Level::Fail,
498            detail: "missing".into(),
499            remedy: Some(remedy.into()),
500        }
501    }
502}
503
504/// `path`/`project-local` beats `npx` beats `missing`; among installed, prefer
505/// the first argument (caller order).
506fn pick_better<'a>(a: &'a Probe, b: &'a Probe) -> Option<&'a Probe> {
507    fn rank(p: &Probe) -> u8 {
508        if p.is_present() {
509            2
510        } else if p.is_provisionable() {
511            1
512        } else {
513            0
514        }
515    }
516    let (ra, rb) = (rank(a), rank(b));
517    if ra == 0 && rb == 0 {
518        None
519    } else if ra >= rb {
520        Some(a)
521    } else {
522        Some(b)
523    }
524}
525
526fn present_detail(probe: &Probe) -> String {
527    let ver = probe
528        .version
529        .map(|v| format!("v{v}"))
530        .unwrap_or_else(|| "installed".into());
531    format!("{ver} ({})", probe.provenance.token())
532}