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 compile = compile_report(compiler);
147    // #2240: a project with git dependencies needs `git` to fetch them.
148    if root.is_some_and(has_git_dependencies) {
149        compile.rows.push(detect_plain(
150            tb,
151            "git",
152            "install git — this project has git dependencies, which `bynk fetch` clones",
153        ));
154        compile.level = compile
155            .rows
156            .iter()
157            .map(|r| r.level)
158            .max()
159            .unwrap_or(Level::Ok);
160    }
161    let mut capabilities = vec![compile];
162
163    // Only build the capability the user scoped to (plus the always-on compile
164    // floor), so `--only test` doesn't probe Cloudflare. With no filter, build
165    // them all.
166    let want = |cap: Capability| opts.only.is_none() || opts.only == Some(cap);
167
168    if want(Capability::Test) {
169        let node = detect_node(tb, root, ctx.node_floor);
170        let runner = detect_runner(tb, root);
171        capabilities.push(capability(Capability::Test, vec![node, runner]));
172    }
173    if want(Capability::Deploy) {
174        let node = detect_node(tb, root, ctx.node_floor);
175        let wrangler = detect_wrangler(tb, root);
176        capabilities.push(capability(Capability::Deploy, vec![node, wrangler]));
177    }
178    if want(Capability::Editor) {
179        let lsp = detect_plain(
180            tb,
181            "bynkc-lsp",
182            "install bynkc-lsp (or download from releases)",
183        );
184        capabilities.push(capability(Capability::Editor, vec![lsp]));
185    }
186    if ctx.in_repo && want(Capability::BuildFromSource) {
187        let cargo = detect_plain(tb, "cargo", "install Rust via https://rustup.rs");
188        capabilities.push(capability(Capability::BuildFromSource, vec![cargo]));
189    }
190
191    Report {
192        driver_version: crate::DRIVER_VERSION.to_string(),
193        compiler: compiler.clone(),
194        capabilities,
195    }
196}
197
198/// Compile/check/fmt. The compiler is **linked in-process** (slice 7 / ADR 0101),
199/// so it is always available and cannot skew against itself — the always-ok row.
200/// The external-`bynkc` resolution + skew check applies **only** under a
201/// `BYNK_BYNKC` override (`Origin::Override`), the one path on which a second,
202/// skewable compiler enters; with no override there is nothing external to check
203/// (amends ADR 0084).
204fn compile_report(compiler: &Compiler) -> CapabilityReport {
205    let mut rows = vec![Row {
206        label: "compiler".into(),
207        level: Level::Ok,
208        detail: "in-process".into(),
209        remedy: None,
210    }];
211
212    // Only when the user explicitly pointed `bynk` at an external compiler does a
213    // second binary — and thus skew — exist. Report it then, and only then.
214    if matches!(compiler.origin, Some(Origin::Override)) {
215        let ver = compiler
216            .version
217            .map(|v| v.to_string())
218            .unwrap_or_else(|| "unknown".into());
219        let row = match (&compiler.path, compiler.skew) {
220            (None, _) => Row {
221                label: "bynkc (override)".into(),
222                level: Level::Fail,
223                detail: "$BYNK_BYNKC set but not found".into(),
224                remedy: Some("fix BYNK_BYNKC, or unset it to use the in-process compiler".into()),
225            },
226            (Some(_), Some(Skew::Major)) => Row {
227                label: "bynkc (override)".into(),
228                level: Level::Fail,
229                detail: format!("{ver} — major skew vs driver"),
230                remedy: Some("align the override bynkc with bynk, or unset BYNK_BYNKC".into()),
231            },
232            (Some(_), Some(Skew::Minor)) => Row {
233                label: "bynkc (override)".into(),
234                level: Level::Warn,
235                detail: format!("{ver} — minor skew vs driver"),
236                remedy: Some("align the override bynkc with bynk, or unset BYNK_BYNKC".into()),
237            },
238            // Resolved but its version could not be read — the binary may not
239            // be a bynkc at all; don't render it as a clean Ok.
240            (Some(_), None) => Row {
241                label: "bynkc (override)".into(),
242                level: Level::Warn,
243                detail: "resolved, but its version could not be read".into(),
244                remedy: Some("check that BYNK_BYNKC points at a bynkc binary".into()),
245            },
246            (Some(_), _) => Row {
247                label: "bynkc (override)".into(),
248                level: Level::Ok,
249                detail: format!("{ver} (override)"),
250                remedy: None,
251            },
252        };
253        rows.push(row);
254    }
255
256    let level = rows.iter().map(|r| r.level).max().unwrap_or(Level::Ok);
257    CapabilityReport {
258        capability: Capability::Compile,
259        optional: false,
260        rows,
261        level,
262    }
263}
264
265/// Aggregate a capability from its rows (worst row wins).
266fn capability(cap: Capability, rows: Vec<Row>) -> CapabilityReport {
267    let level = rows.iter().map(|r| r.level).max().unwrap_or(Level::Ok);
268    CapabilityReport {
269        capability: cap,
270        optional: cap.is_optional(),
271        rows,
272        level,
273    }
274}
275
276fn detect_node(tb: &dyn Toolbox, root: Option<&std::path::Path>, floor: u32) -> Row {
277    // A runtime is never npx-provisionable.
278    let probe = probe::detect(
279        tb,
280        "node",
281        DetectOpts {
282            project_root: root,
283            allow_npx: false,
284        },
285    );
286    let remedy = format!("install Node.js ≥ {floor} from https://nodejs.org");
287    if probe.is_missing() {
288        return Row {
289            label: "node".into(),
290            level: Level::Fail,
291            detail: "missing".into(),
292            remedy: Some(remedy),
293        };
294    }
295    let below = probe.version.map(|v| v.major < floor).unwrap_or(false);
296    if below {
297        let v = probe.version.unwrap();
298        return Row {
299            label: "node".into(),
300            level: Level::Warn,
301            detail: format!("v{v} below floor (≥ {floor})"),
302            remedy: Some(remedy),
303        };
304    }
305    Row {
306        label: "node".into(),
307        level: Level::Ok,
308        detail: present_detail(&probe),
309        remedy: None,
310    }
311}
312
313/// The `tsc | tsx` runner requirement — satisfied by the *better* of the two.
314fn detect_runner(tb: &dyn Toolbox, root: Option<&std::path::Path>) -> Row {
315    let tsc = probe::detect(
316        tb,
317        "tsc",
318        DetectOpts {
319            project_root: root,
320            allow_npx: true,
321        },
322    );
323    let tsx = probe::detect(
324        tb,
325        "tsx",
326        DetectOpts {
327            project_root: root,
328            allow_npx: true,
329        },
330    );
331    let best = pick_better(&tsc, &tsx);
332    let floor = bynk_emit::TYPESCRIPT_MAJOR_FLOOR;
333    let tested = bynk_emit::TYPESCRIPT_MAJOR_TESTED;
334    // #1672: lead with the TypeScript that is verified, and the one that
335    // type-checks; `tsx` only runs the emitted code.
336    let remedy = format!(
337        "npm install -g typescript@{tested} (or `npm install -g tsx`, which runs tests without type-checking)"
338    );
339    match best {
340        // #1672: a `tsc` below the verified floor is unsupported, a real defect
341        // in this environment, so it warns. One above the tested major is
342        // *reported* as untested but stays `ok`: it is a statement about this
343        // repo's verification coverage, not a fault the user has, and a
344        // warning would turn `doctor --strict` red for everyone the day a new
345        // TypeScript major ships, with advice to downgrade a likely-working
346        // toolchain.
347        Some(p) if p.is_present() && p.tool == "tsc" => {
348            let detail = format!("{} {}", p.tool, present_detail(p));
349            match p.version.map(|v| v.major) {
350                Some(major) if major < floor => Row {
351                    label: "tsc | tsx".into(),
352                    level: Level::Warn,
353                    detail: format!("{detail}, below floor (≥ {floor})"),
354                    remedy: Some(remedy),
355                },
356                Some(major) if major > tested => Row {
357                    label: "tsc | tsx".into(),
358                    level: Level::Ok,
359                    detail: format!("{detail}, untested (verified up to {tested})"),
360                    remedy: None,
361                },
362                _ => Row {
363                    label: "tsc | tsx".into(),
364                    level: Level::Ok,
365                    detail,
366                    remedy: None,
367                },
368            }
369        }
370        Some(p) if p.is_present() => Row {
371            label: "tsc | tsx".into(),
372            level: Level::Ok,
373            detail: format!("{} {}", p.tool, present_detail(p)),
374            remedy: None,
375        },
376        Some(p) => Row {
377            // provisionable via npx
378            label: "tsc | tsx".into(),
379            level: Level::Warn,
380            detail: format!("{} provisionable via npx (not installed)", p.tool),
381            remedy: Some(remedy),
382        },
383        None => Row {
384            label: "tsc | tsx".into(),
385            level: Level::Fail,
386            detail: "missing".into(),
387            remedy: Some(remedy),
388        },
389    }
390}
391
392/// #1732: an installed wrangler older than [`bynk_emit::WRANGLER_MIN`], whose
393/// `workerd` therefore refuses the pinned [`bynk_emit::COMPATIBILITY_DATE`].
394/// `false` for an npx-provisioned or unversioned wrangler, which can't be
395/// judged without running it. Shared by `doctor`'s row and `bynk dev`'s notice.
396pub fn wrangler_below_min(probe: &Probe) -> bool {
397    let min = probe::Version::parse(bynk_emit::WRANGLER_MIN).expect("WRANGLER_MIN is a version");
398    probe.is_present()
399        && probe
400            .version
401            .is_some_and(|v| (v.major, v.minor, v.patch) < (min.major, min.minor, min.patch))
402}
403
404/// #1732: how to upgrade a too-old wrangler, for where it came from. A
405/// project's own wrangler (which the driver prefers over `PATH`) is upgraded in
406/// the project, since a global install wouldn't change what runs. Shared by
407/// `doctor`'s row and `bynk dev`'s notice so the two can't disagree. No version
408/// in it: it shows in every `--format short` line and the goldens.
409pub fn wrangler_upgrade_remedy(probe: &Probe) -> &'static str {
410    match probe.provenance {
411        Provenance::ProjectLocal(_) => "npm install --save-dev wrangler@4 (in the project)",
412        _ => "npm install -g wrangler@4",
413    }
414}
415
416/// #1732: wrangler, checked against [`bynk_emit::WRANGLER_MIN`], the oldest
417/// whose `workerd` serves the [`bynk_emit::COMPATIBILITY_DATE`] every generated
418/// `wrangler.toml` pins. An older one's `workerd` refuses the date, so
419/// `bynk dev` fails. It also doesn't read the Durable Object `exports` map
420/// (#1796), so a project with an agent can't deploy on it, and `bynk deploy`
421/// refuses one that would push a class (`deploy::plan::wrangler_floor_refusal`).
422/// A project with no agent still deploys (Cloudflare accepts any past date).
423/// Doctor doesn't know which kind of project it is, so this stays a warning,
424/// not a failure, and `doctor --only deploy` doesn't go red on a toolchain
425/// that deploys an agent-free project. An npx-provisioned wrangler's version isn't known
426/// without running npx (which may download), so its row keeps the usual
427/// "provisionable" warning, and the remedy says to clear a stale npx cache: one
428/// older than the minimum fails the same way, because npx keys the cache on the
429/// spec `wrangler@4`, not the version.
430fn detect_wrangler(tb: &dyn Toolbox, root: Option<&std::path::Path>) -> Row {
431    let min = bynk_emit::WRANGLER_MIN;
432    let date = bynk_emit::COMPATIBILITY_DATE;
433    // No version or date in a remedy: it shows in every `--format short` line
434    // (and the goldens), which a compatibility-date review shouldn't churn. The
435    // below-minimum detail names both. Each remedy fits where wrangler came
436    // from: a project's own wrangler is upgraded in the project, and only an npx
437    // one can be a stale cache.
438    let install = "npm install -g wrangler@4";
439    let probe = probe::detect(
440        tb,
441        "wrangler",
442        DetectOpts {
443            project_root: root,
444            allow_npx: true,
445        },
446    );
447    if wrangler_below_min(&probe) {
448        let remedy = wrangler_upgrade_remedy(&probe);
449        return Row {
450            label: "wrangler".into(),
451            level: Level::Warn,
452            detail: format!(
453                "{}, below {min}: `bynk dev` can't serve compatibility date {date}, and agents can't deploy",
454                present_detail(&probe)
455            ),
456            remedy: Some(remedy.into()),
457        };
458    }
459    let mut row = npm_row("wrangler", &probe, install);
460    if probe.is_provisionable() {
461        row.remedy = Some(format!(
462            "{install}, or clear a stale npx cache (~/.npm/_npx)"
463        ));
464    }
465    row
466}
467
468/// #2240: whether the project at `root` (or its workspace) declares a git
469/// dependency.
470fn has_git_dependencies(root: &std::path::Path) -> bool {
471    let Ok(text) = std::fs::read_to_string(root.join("bynk.toml")) else {
472        return false;
473    };
474    let Ok(doc) = text.parse::<toml::Table>() else {
475        return false;
476    };
477    ["dependencies", "dev-dependencies"].iter().any(|table| {
478        doc.get(*table).and_then(|v| v.as_table()).is_some_and(|t| {
479            t.values()
480                .any(|e| e.as_table().is_some_and(|e| e.contains_key("git")))
481        })
482    })
483}
484
485fn detect_plain(tb: &dyn Toolbox, tool: &str, remedy: &str) -> Row {
486    let probe = probe::detect(
487        tb,
488        tool,
489        DetectOpts {
490            project_root: None,
491            allow_npx: false,
492        },
493    );
494    if probe.is_present() {
495        Row {
496            label: tool.into(),
497            level: Level::Ok,
498            detail: present_detail(&probe),
499            remedy: None,
500        }
501    } else {
502        Row {
503            label: tool.into(),
504            level: Level::Fail,
505            detail: "missing".into(),
506            remedy: Some(remedy.into()),
507        }
508    }
509}
510
511fn npm_row(tool: &str, probe: &Probe, remedy: &str) -> Row {
512    if probe.is_present() {
513        Row {
514            label: tool.into(),
515            level: Level::Ok,
516            detail: present_detail(probe),
517            remedy: None,
518        }
519    } else if probe.is_provisionable() {
520        Row {
521            label: tool.into(),
522            level: Level::Warn,
523            detail: "provisionable via npx (not installed)".into(),
524            remedy: Some(remedy.into()),
525        }
526    } else {
527        Row {
528            label: tool.into(),
529            level: Level::Fail,
530            detail: "missing".into(),
531            remedy: Some(remedy.into()),
532        }
533    }
534}
535
536/// `path`/`project-local` beats `npx` beats `missing`; among installed, prefer
537/// the first argument (caller order).
538fn pick_better<'a>(a: &'a Probe, b: &'a Probe) -> Option<&'a Probe> {
539    fn rank(p: &Probe) -> u8 {
540        if p.is_present() {
541            2
542        } else if p.is_provisionable() {
543            1
544        } else {
545            0
546        }
547    }
548    let (ra, rb) = (rank(a), rank(b));
549    if ra == 0 && rb == 0 {
550        None
551    } else if ra >= rb {
552        Some(a)
553    } else {
554        Some(b)
555    }
556}
557
558fn present_detail(probe: &Probe) -> String {
559    let ver = probe
560        .version
561        .map(|v| format!("v{v}"))
562        .unwrap_or_else(|| "installed".into());
563    format!("{ver} ({})", probe.provenance.token())
564}