From 5368037846453dea60751b15702ccb0034fb7b01 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sun, 9 Aug 2026 21:27:59 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20`behold=20doctor`=20=E2=80=94=20the=20r?= =?UTF-8?q?ead-only=20first-touch=20diagnosis=20(#236)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven lines, each pass/warn/fail with a one-line fix: the project's kind (chant project, estate root, neither), the chant install behold will actually shell and its version against behold's declared floor, declared lexicons against what's installed, the envs the picker will infer, the kube context chant binds versus the ambient one, substrate readiness without starting a server, and committed Ops plus whether chant's MCP is reachable at all. Exit 0 iff nothing failed; `--json` for machines. A reuse surface, not new probing: every fact comes from the module the server already reads it from. Three pieces of detection that were missing went into the shared modules rather than into doctor — `detectProjectShape` (project.ts: chant.config.* / `.behold.json` members / npm workspaces), which `cli.ts`'s startup warning now shares so an estate root gets the member list instead of "no chant.config.ts"; and `resolveChant`/`resolveLexicons`/`chantFloor`/`meetsFloor` (chant.ts), with `chantBin` re-expressed through the first of them. `resolveChant` compares package roots, not requires, to tell the project's own chant from behold's fallback: Node resolution walks up parent directories, so every bundled example resolves behold's chant without having one, and the check that exists to catch a missing install would have missed it. The severity split is the point. A fail stops behold serving the project (no chant in the project's node_modules is the common first-touch one). A warn costs the live half, not the graph — no envs, no cluster binding, a down emulator — so a k8s project with no kube context warns. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 11 +- README.md | 9 + docs/src/content/docs/start/your-project.mdx | 21 ++ src/chant.test.ts | 73 ++++ src/chant.ts | 146 +++++++- src/cli.ts | 63 +++- src/doctor.test.ts | 312 ++++++++++++++++ src/doctor.ts | 352 +++++++++++++++++++ src/project.test.ts | 60 +++- src/project.ts | 66 +++- 10 files changed, 1089 insertions(+), 24 deletions(-) create mode 100644 src/doctor.test.ts create mode 100644 src/doctor.ts diff --git a/AGENTS.md b/AGENTS.md index e45dc19..620b356 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ apply creds. ## Getting a server ```sh -npx @intentius/behold serve --port 4600 # or: preview / demo +npx @intentius/behold serve --port 4600 # or: preview / demo / doctor ``` `behold demo` needs no project at all — it copies the bundled example and serves @@ -28,7 +28,14 @@ graph. ## The read loop 0. **discover** — GET `/api` lists every route with a one-line description, - plus the server's version and a link back to this guide. + plus the server's version and a link back to this guide. Before the server + exists (or when a route answers with an error you'd have to guess at), run + `npx @intentius/behold doctor --json`: a read-only diagnosis of the + project's kind, its own chant install and version, declared lexicons, the + envs the picker will infer, the bound kube context versus the ambient one, + substrate readiness and committed Ops. Each check is + `{name, status: pass|warn|fail, detail, fix}`; the process exits non-zero + iff something failed. It starts no server and changes nothing. 1. **observe** — GET `/api/graph` (JSON: `{ ir, svg, meta }`). The mixed graph of the project, every node with `id`/`kind`/`lexicon`/`attrs`/`sourceLoc`. Drift status, when present, is `attrs._status` (`good`=managed, `warn`=foreign, diff --git a/README.md b/README.md index 3a70bad..45fe959 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,19 @@ Already have a chant project? ```sh cd my-chant-project +npx @intentius/behold doctor # will this project serve well? (read-only) npx @intentius/behold preview # → http://localhost:4600, this project's graph npx @intentius/behold serve . --env prod --poll 30 # live drift overlay ``` +`behold doctor` is the first thing to run on a project behold hasn't seen: one +line each for the project's kind, its own chant install and version, declared +lexicons, the envs the picker will infer, the kube context chant binds versus +your ambient one, substrate readiness and committed Ops — pass/warn/fail with +a one-line fix. It starts nothing and changes nothing; it exits non-zero only +when something would actually stop behold serving the project well, so CI can +gate on it. `--json` for scripts and agents. + Driving it from an agent or script? `GET /api` lists every JSON route; [AGENTS.md](./AGENTS.md) (shipped in the package) is the read/act contract. diff --git a/docs/src/content/docs/start/your-project.mdx b/docs/src/content/docs/start/your-project.mdx index e422aab..8e231f4 100644 --- a/docs/src/content/docs/start/your-project.mdx +++ b/docs/src/content/docs/start/your-project.mdx @@ -20,6 +20,27 @@ That serves your project's declared graph at **http://localhost:4600**. It shell `preview` with no path opens the current directory, so running it from inside your project is the shortest thing that works. +## If it doesn't look right: `behold doctor` + +```sh +npx behold doctor # the current directory +npx behold doctor ../my-project --json +``` + +A read-only diagnosis of everything behold needs to serve the project well — +one line each for the project's kind, its own `chant` install and version, +declared lexicons, the environments the picker will infer, the kube context +chant binds versus your ambient one, substrate readiness, and committed Ops. +Each is pass, warn or fail with a one-line fix; the exit code is non-zero only +when something failed, so CI can gate on it. It starts no server and changes +nothing. + +The distinction it draws is the useful one: a **fail** is something that stops +behold serving the project (no `chant` in the project's `node_modules` is the +common first-touch one — behold shells your project's chant, so it has to be +installed). A **warn** costs you the live half, not the graph: no environment +declared, no cluster binding, an emulator that isn't up. + ## Adding the live overlay To colour the graph by what is actually deployed, name an environment: diff --git a/src/chant.test.ts b/src/chant.test.ts index b62a603..4b6dc0b 100644 --- a/src/chant.test.ts +++ b/src/chant.test.ts @@ -21,6 +21,10 @@ import { classifyChantFailure, ChantCliError, graphIr, + meetsFloor, + chantFloor, + resolveChant, + resolveLexicons, } from "./chant.ts"; import { overlayStatus } from "./overlay.ts"; @@ -685,3 +689,72 @@ describe("graphIr — the stack lens (#76) reaches the actual chant graph invoca expect(args).toEqual(["graph", join(dir, "stacks/api"), "--format", "ir"]); }); }); + +// #236: the chant install behold will actually shell for a project, its +// version against behold's own floor, and the project's lexicon installs — +// what `behold doctor` reports and what the graph routes act on. +describe("meetsFloor", () => { + it("compares dotted versions numerically, not lexically (0.9.0 beats 0.38.0 only if you compare strings)", () => { + expect(meetsFloor("0.44.2", "0.38.0")).toBe(true); + expect(meetsFloor("0.9.0", "0.38.0")).toBe(false); + expect(meetsFloor("0.38.0", "0.38.0")).toBe(true); + expect(meetsFloor("1.0.0", "0.38.0")).toBe(true); + }); + + it("treats a prerelease of the floor as meeting it", () => { + expect(meetsFloor("0.38.0-rc.1", "0.38.0")).toBe(true); + }); + + it("never claims a version is stale on missing or unparseable input — unknown is not old", () => { + expect(meetsFloor(undefined, "0.38.0")).toBe(true); + expect(meetsFloor("0.44.2", undefined)).toBe(true); + expect(meetsFloor("workspace:*", "0.38.0")).toBe(true); + }); + + it("reads behold's own declared floor as a bare version", () => { + expect(chantFloor()).toMatch(/^\d+\.\d+\.\d+$/); + }); +}); + +describe("resolveChant / resolveLexicons", () => { + let dirs: string[] = []; + afterEach(() => { + dirs.forEach((d) => rmSync(d, { recursive: true, force: true })); + dirs = []; + }); + const make = (files: Record): string => { + const dir = mkdtempSync(join(tmpdir(), "behold-resolve-")); + dirs.push(dir); + for (const [rel, content] of Object.entries(files)) { + mkdirSync(dirname(join(dir, rel)), { recursive: true }); + writeFileSync(join(dir, rel), content); + } + return dir; + }; + const pkg = (name: string, version: string): Record => ({ + [`node_modules/${name}/package.json`]: JSON.stringify({ name, version, main: "index.js", bin: { chant: "bin/chant" } }), + [`node_modules/${name}/index.js`]: "", + }); + + it("prefers the project's own chant, and reports its version", () => { + const dir = make(pkg("@intentius/chant", "0.44.2")); + const res = resolveChant(dir); + expect(res.source).toBe("project"); + expect(res.version).toBe("0.44.2"); + // endsWith, not equality: the resolver returns a realpath, and macOS's + // tmpdir is a symlink (/var/folders -> /private/var/folders). + expect(res.bin.endsWith(join("node_modules/@intentius/chant/bin/chant"))).toBe(true); + }); + + it("reports behold's own install as the fallback, not as the project's — a tmp dir has no chant of its own", () => { + expect(resolveChant(make({ "chant.config.ts": "export default {};" })).source).toBe("behold"); + }); + + it("resolves each declared lexicon to its package, installed or not", () => { + const dir = make(pkg("@intentius/chant-lexicon-aws", "0.44.2")); + expect(resolveLexicons(dir, ["aws", "k8s"])).toEqual([ + { lexicon: "aws", pkg: "@intentius/chant-lexicon-aws", installed: true, version: "0.44.2" }, + { lexicon: "k8s", pkg: "@intentius/chant-lexicon-k8s", installed: false }, + ]); + }); +}); diff --git a/src/chant.ts b/src/chant.ts index 890f857..2626910 100644 --- a/src/chant.ts +++ b/src/chant.ts @@ -11,8 +11,9 @@ */ import { spawn } from "node:child_process"; import { targetEnvOverrides, type SubstrateTarget } from "./targets.ts"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; import type { GraphIR, Layout, ComponentStatusRow } from "@intentius/chant"; import { detectProject } from "./project.ts"; @@ -124,21 +125,39 @@ export function graphFlags(opts: GraphOptions): string[] { return flags; } -/** Resolve `@intentius/chant`'s bin path as seen from `req`, walking up from the - * resolved entry to the package root. Returns undefined if unresolvable. */ -export function chantBinFrom(req: ReturnType): string | undefined { +/** A resolved `@intentius/chant` install: where it lives, its bin, its declared + * version (undefined when the manifest carries none). */ +export interface ChantPackage { + /** The package root — the directory holding its package.json. */ + root: string; + /** Absolute path to the `chant` bin. */ + bin: string; + version?: string; +} + +/** Resolve a package as seen from `req` and walk up from the resolved entry to + * its root, returning the manifest. Undefined when the package doesn't resolve. + * + * The walk is not decoration: chant and its lexicons ship raw TypeScript with + * an `exports` map whose `./*` subpath rewrites to `./src/*.ts`, so + * `resolve("/package.json")` resolves to a file that doesn't exist. The + * entry point is the only subpath that reliably resolves; the manifest has to + * be found by walking to it. */ +function packageManifestFrom( + req: ReturnType, + name: string, +): { root: string; manifest: { name?: string; version?: string; bin?: { chant?: string } } } | undefined { let entry: string; try { - entry = req.resolve("@intentius/chant"); + entry = req.resolve(name); } catch { return undefined; } let dir = dirname(entry); for (;;) { - const manifest = join(dir, "package.json"); try { - const pkg = createRequire(import.meta.url)(manifest) as { name?: string; bin?: { chant?: string } }; - if (pkg.name === "@intentius/chant") return join(dir, pkg.bin?.chant ?? "bin/chant"); + const pkg = createRequire(import.meta.url)(join(dir, "package.json")) as { name?: string }; + if (pkg.name === name) return { root: dir, manifest: pkg }; } catch { // keep walking up } @@ -149,14 +168,113 @@ export function chantBinFrom(req: ReturnType): string | un return undefined; } -function chantBin(projectDir?: string): string { +/** Resolve the `@intentius/chant` package as seen from `req`, walking up from + * the resolved entry to the package root. Returns undefined if unresolvable. */ +export function chantPackageFrom(req: ReturnType): ChantPackage | undefined { + const found = packageManifestFrom(req, "@intentius/chant"); + if (!found) return undefined; + const { root, manifest } = found; + return { root, bin: join(root, manifest.bin?.chant ?? "bin/chant"), ...(manifest.version ? { version: manifest.version } : {}) }; +} + +/** One declared lexicon and whether the project can actually load it. `pkg` is + * the npm package a chant lexicon name maps to — `k8s` → + * `@intentius/chant-lexicon-k8s`, the convention every lexicon follows. */ +export interface LexiconResolution { + lexicon: string; + pkg: string; + installed: boolean; + version?: string; +} + +/** The install state of a project's declared lexicons (#236), resolved from + * the project the same way chant itself will resolve them at graph time — a + * missing one is exactly what `classifyChantFailure` later reports as + * "not-installed", said before a graph route has to. */ +export function resolveLexicons(projectDir: string, lexicons: readonly string[]): LexiconResolution[] { + const req = createRequire(join(resolve(projectDir), "noop.js")); + return lexicons.map((lexicon) => { + const pkg = `@intentius/chant-lexicon-${lexicon}`; + const found = packageManifestFrom(req, pkg); + return { lexicon, pkg, installed: !!found, ...(found?.manifest.version ? { version: found.manifest.version } : {}) }; + }); +} + +/** Resolve `@intentius/chant`'s bin path as seen from `req`. Returns undefined + * if unresolvable. */ +export function chantBinFrom(req: ReturnType): string | undefined { + return chantPackageFrom(req)?.bin; +} + +/** Where the chant behold would shell for a project came from. `project` is the + * intended case (the project decides its chant version); `behold` means the + * project's own install is missing and behold fell back to its bundled chant, + * which has none of the project's lexicons — the #1 first-touch failure (#236, + * and `classifyChantFailure`'s "not-installed" arriving later, in a graph + * route, is the symptom); `path` means neither resolved and the bare `chant` + * on PATH (if any) is all that's left. */ +export type ChantSource = "project" | "behold" | "path"; + +export interface ChantResolution extends Partial { + source: ChantSource; + bin: string; +} + +/** The chant behold will actually shell for `projectDir` — the project's own + * install first, then behold's bundled one, then a bare `chant` on PATH. The + * resolution `runChantRaw` uses, exposed so `behold doctor` reports the same + * answer the server acts on rather than probing separately (#236). */ +export function resolveChant(projectDir?: string): ChantResolution { + const own = chantPackageFrom(createRequire(import.meta.url)); if (projectDir) { - const fromProject = chantBinFrom(createRequire(join(resolve(projectDir), "noop.js"))); - if (fromProject) return fromProject; + const fromProject = chantPackageFrom(createRequire(join(resolve(projectDir), "noop.js"))); + // Landing on behold's OWN install is the fallback, however it was reached. + // Node resolution walks up parent directories, so a project nested under + // behold's checkout (every bundled example is) resolves behold's chant + // without having one of its own — reporting that as "the project's" would + // make `behold doctor` confidently miss the missing install it exists to + // catch. Comparing package roots, not requires, is what distinguishes them. + if (fromProject) return { source: fromProject.root === own?.root ? "behold" : "project", ...fromProject }; } - const own = chantBinFrom(createRequire(import.meta.url)); - if (own) return own; - return "chant"; + if (own) return { source: "behold", ...own }; + return { source: "path", bin: "chant" }; +} + +/** behold's declared `@intentius/chant` floor — the caret range in its own + * package.json reduced to the literal version (`^0.38.0` → `0.38.0`), the same + * reading `scripts/typecheck-floor.sh` typechecks against. Undefined when the + * manifest can't be read. */ +export function chantFloor(): string | undefined { + try { + const here = dirname(fileURLToPath(import.meta.url)); + const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as { + dependencies?: Record; + }; + return pkg.dependencies?.["@intentius/chant"]?.replace(/^[^0-9]*/, "") || undefined; + } catch { + return undefined; + } +} + +/** Does `version` meet `floor`? Numeric dotted compare, prerelease suffix + * dropped (`0.44.3-rc.1` compares as `0.44.3` — an rc of the floor is close + * enough to not warn about). Unparseable input answers `true`: an unknown + * version is not evidence of an old one. Pure; exported for testing. */ +export function meetsFloor(version: string | undefined, floor: string | undefined): boolean { + if (!version || !floor) return true; + const parts = (v: string): number[] => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10)); + const a = parts(version); + const b = parts(floor); + if (a.some(Number.isNaN) || b.some(Number.isNaN)) return true; + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const d = (a[i] ?? 0) - (b[i] ?? 0); + if (d !== 0) return d > 0; + } + return true; +} + +function chantBin(projectDir?: string): string { + return resolveChant(projectDir).bin; } export interface ChantRun { diff --git a/src/cli.ts b/src/cli.ts index 0ab23a9..8aa09d7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,16 +10,27 @@ import { fileURLToPath } from "node:url"; import { startServer, beholdVersion } from "./server.ts"; import { loadDemoRegistry, missingRequirements } from "./demos.ts"; import { runExport } from "./export.ts"; +import { diagnose, formatReport } from "./doctor.ts"; import { isAutoSyncMode, type AutoSyncMode } from "./autosync.ts"; +import { detectProjectShape } from "./project.ts"; const USAGE = `behold — a live control plane on chant (read-only core) Usage: behold demo [name] [target-dir] [--port ] [--list] + behold doctor [project-dir] [--json] behold preview [project-dir] [--port ] [--emulator] behold export [project-dir] [--out ] [--env ] [--name ] [--emulator] behold serve [--port ] [--env ] [--poll ] [--local] + doctor Why won't this project serve well? A read-only diagnosis of + everything behold needs — the project's kind, its own chant install + and version, declared lexicons, the envs the picker will infer, the + kube context chant binds versus the ambient one, substrate + readiness, committed Ops — each line pass/warn/fail with a one-line + fix. Defaults to the current directory. Exits 0 iff nothing failed; + --json for machines. Starts no server and changes nothing. + demo The five-minute path from npm — no chant project needed. A catalog of demo estates (behold demo --list): bundled ones copy out of the package into a directory that's yours to edit; git ones shallow- @@ -80,6 +91,8 @@ Options: names, unlike --local; kept separate because Loom's own \`scripts/local/local-up.sh\` Floci setup clashes on :4566 with chant's generic \`chant emulator up\`. + --json doctor only: the report as JSON (stable keys) instead of + the console lines — the AGENTS.md audience. --out export only: output directory (default ./behold-export). --name export only: Cloudflare Worker name in the generated wrangler.jsonc. @@ -105,6 +118,11 @@ export async function run(argv: string[]): Promise { return; } + if (cmd === "doctor") { + await runDoctor(rest); + return; + } + if (cmd === "preview") { await runPreview(rest); return; @@ -188,15 +206,54 @@ export async function run(argv: string[]): Promise { * breath as the URL — the server's /api/graph 404s with the structured * no-project card, and this is the terminal-side half of the same honesty. * A warning, not an exit: serving anyway is right (the card explains, and - * the directory may be about to become a project). */ + * the directory may be about to become a project). + * + * Shares `detectProjectShape` (#236) with `behold doctor`, so both agree on + * what a directory is — including the estate-root case, where the right + * advice is the member list rather than "no chant.config.ts". */ function warnIfNotChantProject(dir: string): void { - if (existsSync(join(dir, "chant.config.ts"))) return; + const shape = detectProjectShape(dir); + if (shape.kind === "project") return; + if (shape.kind === "estate") { + process.stderr.write( + `behold: warning — ${dir} is an estate root, not a chant project itself.\n` + + ` Serve its members composed: behold serve ${shape.members!.map((m) => join(dir, m)).join(" ")}\n`, + ); + return; + } process.stderr.write( `behold: warning — ${dir} has no chant.config.ts; this doesn't look like a chant project.\n` + - ` No project yet? \`behold demo\` serves a bundled working example (needs Docker).\n`, + ` \`behold doctor ${dir}\` says what's missing. No project yet? \`behold demo\` serves a bundled working example (needs Docker).\n`, ); } +/** `behold doctor` (#236) — the read-only first-touch diagnosis. Composes the + * probes the server already uses (src/doctor.ts) into one console report; + * starts nothing, writes nothing. Exit 1 (via `process.exitCode`, so stdout + * flushes) when any line failed, 0 otherwise — a CI-usable gate on "can behold + * serve this project well". */ +async function runDoctor(rest: string[]): Promise { + let json = false; + let dirArg: string | undefined; + for (const a of rest) { + if (a === "--json") json = true; + else if (a === "-h" || a === "--help") return void process.stdout.write(USAGE); + else if (!a.startsWith("-")) dirArg = a; + else { + process.stderr.write(`behold doctor: unexpected argument '${a}'\n`); + process.exit(2); + } + } + const dir = dirArg ?? "."; + if (!existsSync(resolve(dir))) { + process.stderr.write(`behold doctor: no such directory: ${resolve(dir)}\n`); + process.exit(2); + } + const report = await diagnose(dir); + process.stdout.write(json ? JSON.stringify(report, null, 2) + "\n" : formatReport(report)); + if (!report.ok) process.exitCode = 1; +} + /** `behold demo` (#193) — the five-minute path for someone who just ran * `npm install @intentius/behold` and has no chant project: copy the bundled * example-writes into a directory THEY own (so editing its source and diff --git a/src/doctor.test.ts b/src/doctor.test.ts new file mode 100644 index 0000000..f55632b --- /dev/null +++ b/src/doctor.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { diagnose, formatReport, type DoctorProbes, type DoctorReport, type DoctorCheck } from "./doctor.ts"; +import type { Kubeconfig } from "./k8s-target.ts"; +import type { Substrate } from "./substrates.ts"; + +// Fixtures are built in the OS tmpdir rather than pointed at the bundled +// examples: example-writes' node_modules is intentionally absent in a fresh +// checkout and intentionally present after `just example-install`, so a test +// keyed on either state passes only half the time. A tmp fixture states which +// case it is. +const made: string[] = []; +afterEach(() => { + while (made.length) rmSync(made.pop()!, { recursive: true, force: true }); +}); + +/** A fixture directory: `{ "rel/path": contents }`, parents created. */ +function fixture(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), "behold-doctor-")); + made.push(dir); + for (const [rel, content] of Object.entries(files)) { + const path = join(dir, rel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + } + return dir; +} + +/** A fake installed package under the fixture's own node_modules — enough for + * Node's resolver to find it (an entry point that exists) and for the manifest + * walk to read its version. */ +function installed(name: string, version: string, bin?: string): Record { + const root = `node_modules/${name}`; + return { + [`${root}/package.json`]: JSON.stringify({ name, version, main: "index.js", ...(bin ? { bin: { chant: bin } } : {}) }), + [`${root}/index.js`]: "", + ...(bin ? { [`${root}/${bin}`]: "#!/usr/bin/env node\n" } : {}), + }; +} + +const CHANT = installed("@intentius/chant", "0.44.2", "bin/chant"); + +const kubeconfig = (over: Partial = {}): Kubeconfig => ({ + contexts: new Map(), + servers: new Map(), + ...over, +}); + +/** Probes for a machine with nothing running and nothing installed — the + * default for these tests, so no result depends on the host's Docker or + * kubeconfig. */ +const probes = (over: DoctorProbes = {}): DoctorProbes => ({ + loadKubeconfig: async () => kubeconfig(), + detectSubstrates: async () => [], + ...over, +}); + +const up: Substrate[] = [{ name: "docker", label: "Docker", status: "up", detail: "daemon running" }]; + +const by = (report: DoctorReport, name: DoctorCheck["name"]): DoctorCheck => report.checks.find((c) => c.name === name)!; + +describe("diagnose", () => { + it("passes every line for a healthy project — installed chant + lexicon, an env, committed Ops", async () => { + const dir = fixture({ + "chant.config.ts": `export default { lexicons: ["aws"], environments: ["prod"], sourceDir: "src" };`, + "package.json": JSON.stringify({ name: "healthy", dependencies: { "@intentius/chant": "^0.44.2" } }), + "src/main.ts": "", + "ops/deploy.op.ts": `export const op = { name: "prod-apply", kind: ApplyOp };`, + ...CHANT, + ...installed("@intentius/chant-lexicon-aws", "0.44.2"), + }); + + const report = await diagnose(dir, probes({ detectSubstrates: async () => up })); + + expect(report.ok).toBe(true); + expect(report.kind).toBe("project"); + expect(report.checks.map((c) => c.status)).toEqual(["pass", "pass", "pass", "pass", "pass", "pass", "pass"]); + expect(by(report, "chant").detail).toContain("chant 0.44.2"); + expect(by(report, "lexicons").detail).toContain("aws 0.44.2"); + expect(by(report, "envs").detail).toContain("prod"); + expect(by(report, "ops").detail).toContain("prod-apply (apply)"); + expect(report.checks.every((c) => c.fix === undefined)).toBe(true); + }); + + it("fails with one line, and the fix, for a directory that is no project at all (#193's dead end)", async () => { + const report = await diagnose(fixture({ "notes.txt": "hello" }), probes()); + + expect(report.ok).toBe(false); + expect(report.kind).toBe("none"); + expect(report.checks).toHaveLength(1); + expect(by(report, "project").status).toBe("fail"); + expect(by(report, "project").fix).toContain("behold demo"); + }); + + it("fails, naming npm install, when the project's own chant and lexicons aren't installed", async () => { + const dir = fixture({ + "chant.config.ts": `export default { lexicons: ["aws"], environments: ["prod"] };`, + "package.json": JSON.stringify({ name: "uninstalled", dependencies: { "@intentius/chant": "^0.44.2" } }), + }); + + const report = await diagnose(dir, probes()); + + expect(report.ok).toBe(false); + expect(by(report, "chant").status).toBe("fail"); + expect(by(report, "chant").detail).toContain("no chant installed"); + expect(by(report, "chant").fix).toContain("npm install"); + expect(by(report, "lexicons").status).toBe("fail"); + expect(by(report, "lexicons").fix).toContain("npm install"); + // The act loop goes with it — chant's MCP is served by the project's chant. + expect(by(report, "ops").detail).toContain("chant MCP unavailable"); + }); + + it("warns rather than fails for a k8s project with no kube context — the declared graph still serves", async () => { + const dir = fixture({ + "chant.config.ts": `export default { lexicons: ["k8s"], k8s: { profiles: { local: { context: "k3d-demo" } } } };`, + ...CHANT, + ...installed("@intentius/chant-lexicon-k8s", "0.44.2"), + }); + + const report = await diagnose(dir, probes()); + + expect(report.ok).toBe(true); + expect(by(report, "kube").status).toBe("warn"); + expect(by(report, "kube").detail).toContain("no kubeconfig readable"); + expect(by(report, "kube").fix).toContain("kubectl"); + }); + + it("reports the bound context and its apiserver when the kubeconfig carries it", async () => { + const dir = fixture({ + "chant.config.ts": `export default { lexicons: ["k8s"], k8s: { profiles: { local: { context: "k3d-demo" } } } };`, + ...CHANT, + ...installed("@intentius/chant-lexicon-k8s", "0.44.2"), + }); + const kc = kubeconfig({ + contexts: new Map([["k3d-demo", "k3d-demo-cluster"]]), + servers: new Map([["k3d-demo-cluster", "https://127.0.0.1:6443"]]), + currentContext: "k3d-demo", + }); + + const report = await diagnose(dir, probes({ loadKubeconfig: async () => kc })); + + expect(by(report, "kube").status).toBe("pass"); + expect(by(report, "kube").detail).toBe("bound local: k3d-demo at https://127.0.0.1:6443"); + // The env picker infers k8s.profiles keys when no environments array is + // declared (#191) — the same inference the server's picker makes. + expect(by(report, "envs").detail).toContain("local"); + }); + + it("warns when the ambient current-context differs from the declared binding — chant refuses the live read", async () => { + const dir = fixture({ + "chant.config.ts": `export default { lexicons: ["k8s"], k8s: { profiles: { local: { context: "k3d-demo" } } } };`, + ...CHANT, + ...installed("@intentius/chant-lexicon-k8s", "0.44.2"), + }); + const kc = kubeconfig({ + contexts: new Map([ + ["k3d-demo", "k3d-demo-cluster"], + ["prod-eks", "prod"], + ]), + servers: new Map([ + ["k3d-demo-cluster", "https://127.0.0.1:6443"], + ["prod", "https://prod.example"], + ]), + currentContext: "prod-eks", + }); + + const report = await diagnose(dir, probes({ loadKubeconfig: async () => kc })); + + expect(report.ok).toBe(true); + expect(by(report, "kube").status).toBe("warn"); + expect(by(report, "kube").detail).toContain("ambient current-context is prod-eks"); + expect(by(report, "kube").fix).toBe("Run `kubectl config use-context k3d-demo` before the live overlay."); + }); + + it("warns, never fails, on a down substrate — and names its bring-up", async () => { + const dir = fixture({ + "chant.config.ts": `export default { lexicons: ["aws"], environments: ["prod"] };`, + ...CHANT, + ...installed("@intentius/chant-lexicon-aws", "0.44.2"), + }); + const down: Substrate[] = [ + { name: "docker", label: "Docker", status: "up", detail: "daemon running" }, + { name: "floci", label: "Floci", status: "down", detail: "not running", bringUp: { label: "local-up", cmd: "bash", args: ["scripts/local/local-up.sh"] } }, + ]; + + const report = await diagnose(dir, probes({ detectSubstrates: async () => down })); + + expect(report.ok).toBe(true); + expect(by(report, "substrates").status).toBe("warn"); + expect(by(report, "substrates").detail).toBe("Docker up, Floci down"); + expect(by(report, "substrates").fix).toContain("bash scripts/local/local-up.sh"); + }); + + it("says plainly that no declared envs means no live overlay", async () => { + const dir = fixture({ "chant.config.ts": `export default { lexicons: [] };`, ...CHANT }); + + const report = await diagnose(dir, probes()); + + expect(report.ok).toBe(true); + expect(by(report, "envs").status).toBe("warn"); + expect(by(report, "envs").detail).toContain("no live overlay"); + }); + + it("warns when the project ships no committed Ops — drift visible, nothing to trigger", async () => { + const dir = fixture({ "chant.config.ts": `export default { lexicons: [], environments: ["prod"] };`, ...CHANT }); + + const report = await diagnose(dir, probes()); + + expect(by(report, "ops").status).toBe("warn"); + expect(by(report, "ops").fix).toContain("ApplyOp"); + }); + + it("diagnoses an estate root across its members, and points serve at them rather than the root", async () => { + const dir = fixture({ + ".behold.json": JSON.stringify({ members: ["a", "b"] }), + "a/chant.config.ts": `export default { lexicons: ["aws"], environments: ["prod"] };`, + "a/ops/apply.op.ts": `export const op = { name: "a-apply", kind: ApplyOp };`, + "b/chant.config.ts": `export default { lexicons: ["aws"], environments: ["staging"] };`, + ...CHANT, + ...installed("@intentius/chant-lexicon-aws", "0.44.2"), + }); + + const report = await diagnose(dir, probes({ detectSubstrates: async () => up })); + + expect(report.kind).toBe("estate"); + expect(by(report, "project").detail).toContain(".behold.json members): a, b"); + expect(by(report, "chant").detail).toBe("a: chant 0.44.2, b: chant 0.44.2 (behold's floor 0.38.0)"); + expect(by(report, "envs").detail).toContain(`behold serve ${dir}/a ${dir}/b --env prod`); + expect(by(report, "ops").detail).toContain("a: a-apply (apply)"); + }); + + it("detects an estate from npm workspaces when no .behold.json declares members", async () => { + const dir = fixture({ + "package.json": JSON.stringify({ name: "estate", workspaces: ["control-plane", "not-a-project"] }), + "control-plane/chant.config.ts": `export default { lexicons: [], environments: ["local"] };`, + "not-a-project/package.json": "{}", + ...CHANT, + }); + + const report = await diagnose(dir, probes()); + + expect(report.kind).toBe("estate"); + // A workspace that isn't a chant project is not a member. + expect(by(report, "project").detail).toBe("estate of 1 projects (npm workspaces): control-plane"); + }); +}); + +describe("--json", () => { + it("is a stable shape: behold/dir/kind/ok plus a check per line, keyed by name", async () => { + const dir = fixture({ + "chant.config.ts": `export default { lexicons: ["aws"], environments: ["prod"] };`, + "ops/apply.op.ts": `export const op = { name: "prod-apply", kind: ApplyOp };`, + ...CHANT, + ...installed("@intentius/chant-lexicon-aws", "0.44.2"), + }); + + const report = await diagnose(dir, probes({ detectSubstrates: async () => up })); + const round = JSON.parse(JSON.stringify(report)) as DoctorReport; + + expect(Object.keys(round)).toEqual(["behold", "dir", "kind", "ok", "checks"]); + expect(round.dir).toBe(dir); + expect(round.kind).toBe("project"); + expect(round.ok).toBe(true); + expect(round.checks.map((c) => c.name)).toEqual(["project", "chant", "lexicons", "envs", "kube", "substrates", "ops"]); + for (const c of round.checks) { + expect(Object.keys(c).filter((k) => k !== "fix")).toEqual(["name", "status", "detail"]); + expect(["pass", "warn", "fail"]).toContain(c.status); + expect(typeof c.detail).toBe("string"); + } + // Every non-pass line carries its one-line fix; a pass never does. + for (const c of round.checks) expect(c.fix === undefined).toBe(c.status === "pass"); + }); +}); + +describe("formatReport", () => { + const report: DoctorReport = { + behold: "0.7.0", + dir: "/tmp/project", + kind: "project", + ok: false, + checks: [ + { name: "project", status: "pass", detail: "chant project (chant.config.ts)" }, + { name: "chant", status: "fail", detail: "no chant installed — behold shells the project's own chant", fix: "Run `npm install` in /tmp/project" }, + { name: "lexicons", status: "pass", detail: "none declared" }, + { name: "envs", status: "pass", detail: "prod — `behold serve . --env prod` for the live overlay" }, + { name: "kube", status: "pass", detail: "no k8s or helm lexicon — no cluster binding to check" }, + { name: "substrates", status: "warn", detail: "Docker down", fix: "Bring up: Docker (open -a Docker)" }, + { name: "ops", status: "pass", detail: "1 committed: prod-apply (apply); chant MCP unavailable until the project's chant is installed" }, + ], + }; + + it("renders the console report", () => { + expect(formatReport(report)).toMatchInlineSnapshot(` + "behold doctor 0.7.0 — /tmp/project + + pass project chant project (chant.config.ts) + fail chant no chant installed — behold shells the project's own chant + fix Run \`npm install\` in /tmp/project + pass lexicons none declared + pass envs prod — \`behold serve . --env prod\` for the live overlay + pass kube no k8s or helm lexicon — no cluster binding to check + warn substrates Docker down + fix Bring up: Docker (open -a Docker) + pass ops 1 committed: prod-apply (apply); chant MCP unavailable until the project's chant is installed + + 5 pass, 1 warn, 1 fail + " + `); + }); +}); diff --git a/src/doctor.ts b/src/doctor.ts new file mode 100644 index 0000000..9e5c3ba --- /dev/null +++ b/src/doctor.ts @@ -0,0 +1,352 @@ +/** + * `behold doctor` (#236) — the read-only first-touch diagnosis. + * + * #193 got a stranger from "blank graph" to "structured error". The next + * question is *why*, and the answers were scattered across error payloads, + * zoom notes and server logs. This gathers them into one console report: seven + * lines, each pass/warn/fail with a one-line fix, exit 0 iff nothing failed. + * + * It is deliberately a REUSE surface, not a second implementation. Every fact + * here comes from the module the server already reads it from — `project.ts` + * for the shape and the config, `chant.ts` for the bin/version/lexicon + * resolution behold will actually shell, `k8s-target.ts` for the cluster + * binding (the same kubectl-owned merge, never a parallel kubeconfig parser), + * `substrates.ts` for readiness, `ops.ts` for committed Ops. Where a check + * needed logic the server didn't have (estate shape, chant version vs floor, + * lexicon install state), that logic went into the shared module and the + * server's own paths resolve through it too. + * + * Read-only throughout: no writes, no cluster mutations, no network beyond + * what a `behold serve` of the same directory would already do. + * + * The severity convention is the whole point of the output being useful: + * fail — behold cannot serve this project well, and the fix is named. + * warn — behold serves, with less: the declared graph is still a graph. + * A missing cluster binding is a warn for exactly this reason. + * pass — nothing to do. + */ +import { relative, resolve } from "node:path"; +import { + chantFloor, + meetsFloor, + resolveChant, + resolveLexicons, + type ChantResolution, +} from "./chant.ts"; +import { detectProject, detectProjectShape, type ProjectKind } from "./project.ts"; +import { loadKubeconfig, resolveK8sTarget, type K8sProfiles, type Kubeconfig } from "./k8s-target.ts"; +import { detectSubstrates, type Substrate } from "./substrates.ts"; +import { discoverEstateOps, type OpInfo } from "./ops.ts"; +import { beholdVersion } from "./server.ts"; + +export type CheckStatus = "pass" | "warn" | "fail"; + +/** One diagnosis line. `name` is a stable machine key (`--json` consumers key + * on it); `detail` is what behold found; `fix` is the single next step, set + * whenever the status isn't a pass. */ +export interface DoctorCheck { + name: "project" | "chant" | "lexicons" | "envs" | "kube" | "substrates" | "ops"; + status: CheckStatus; + detail: string; + fix?: string; +} + +export interface DoctorReport { + /** behold's own version — the thing doing the diagnosing. */ + behold: string; + /** The directory diagnosed, absolute. */ + dir: string; + kind: ProjectKind; + /** True iff no check failed — the process exit code is `ok ? 0 : 1`. */ + ok: boolean; + checks: DoctorCheck[]; +} + +/** The two live probes, injectable so the diagnosis is testable without a + * Docker daemon or a kubeconfig on the machine running the tests. Defaults are + * the real ones the server uses. */ +export interface DoctorProbes { + loadKubeconfig?: () => Promise; + detectSubstrates?: (projectDir: string, preview?: boolean, boundContext?: string) => Promise; +} + +const list = (xs: readonly string[]): string => xs.join(", "); + +const EMPTY_KUBECONFIG: Kubeconfig = { contexts: new Map(), servers: new Map() }; + +/** A long list, cut to a readable few — an operator with 14 kubeconfig + * contexts doesn't need all of them inside a fix line. */ +function summarize(xs: readonly string[], max = 4): string { + return xs.length <= max ? list(xs) : `${list(xs.slice(0, max))}, +${xs.length - max} more`; +} + +/** Member label for a detail line: the relative dir inside an estate, and + * nothing at all for a single project (where there's only one target and + * naming it every time is noise). */ +function labelFor(root: string, target: string, estate: boolean): string { + if (!estate) return ""; + const rel = relative(root, target); + return `${rel || "."}: `; +} + +/** The chant line: the install behold will actually shell for each target, and + * its version against behold's declared floor. A project with no chant of its + * own is the #1 first-touch failure — behold falls back to its own bundled + * chant, which carries none of the project's lexicons, and the failure surfaces + * much later as an opaque graph error. */ +function chantCheck(root: string, targets: string[], estate: boolean): DoctorCheck { + const floor = chantFloor(); + const resolutions = targets.map((t) => ({ target: t, res: resolveChant(t) })); + const missing = resolutions.filter(({ res }) => res.source !== "project"); + const describe = ({ target, res }: { target: string; res: ChantResolution }): string => + `${labelFor(root, target, estate)}${res.source === "project" ? `chant ${res.version ?? "(unknown version)"}` : "no chant installed"}`; + if (missing.length) { + return { + name: "chant", + status: "fail", + detail: `${list(resolutions.map(describe))} — behold shells the project's own chant`, + fix: `Run \`npm install\` in ${list(missing.map(({ target }) => relative(root, target) || target))}`, + }; + } + const stale = resolutions.filter(({ res }) => !meetsFloor(res.version, floor)); + const versions = list(resolutions.map(describe)); + if (stale.length) { + return { + name: "chant", + status: "warn", + detail: `${versions} — below behold's floor ${floor}`, + fix: "Bump @intentius/chant in the project (`npm install @intentius/chant@latest`) — older versions observe less.", + }; + } + return { name: "chant", status: "pass", detail: `${versions}${floor ? ` (behold's floor ${floor})` : ""}` }; +} + +/** The lexicons line: what the config declares against what's installed. A + * declared-but-missing lexicon is the same "not-installed" chant would raise + * at graph time, said before a graph route has to. */ +function lexiconCheck(root: string, declared: Map, estate: boolean): DoctorCheck { + const missing: string[] = []; + const found: string[] = []; + for (const [target, lexicons] of declared) { + const label = labelFor(root, target, estate); + for (const r of resolveLexicons(target, lexicons)) { + (r.installed ? found : missing).push(`${label}${r.lexicon}${r.installed && r.version ? ` ${r.version}` : ""}`); + } + } + if (missing.length) { + return { + name: "lexicons", + status: "fail", + detail: `declared but not installed: ${list(missing)}${found.length ? ` (installed: ${list(found)})` : ""}`, + fix: "Run `npm install` in the project — chant can't graph source importing a lexicon it can't resolve.", + }; + } + if (!found.length) { + return { name: "lexicons", status: "pass", detail: "none declared" }; + } + return { name: "lexicons", status: "pass", detail: `${found.length} installed: ${list(found)}` }; +} + +/** The envs line: what the env picker will infer. No envs is not broken — it + * means no live overlay, stated plainly instead of discovered by an empty + * dropdown. */ +function envCheck(envs: string[], serveArg: string): DoctorCheck { + if (!envs.length) { + return { + name: "envs", + status: "warn", + detail: "no environments declared — the declared graph serves, but there is no live overlay to pick", + fix: "Declare `environments` (or `k8s.profiles`) in chant.config.ts, then serve with `--env `.", + }; + } + return { name: "envs", status: "pass", detail: `${list(envs)} — \`behold serve ${serveArg} --env ${envs[0]}\` for the live overlay` }; +} + +/** The kube line: the context chant will bind per environment, and whether the + * ambient context agrees with it. + * + * Never a fail. A project whose cluster is unreachable still has a declared + * graph worth serving, and kubectl's absence is an ordinary state for a + * project served source-only. The mismatch case is a warn rather than silence + * because chant refuses a live read outright when the ambient context differs + * from the declared binding (chant#1100) — an opaque "unobserved" downstream. + */ +function kubeCheck(lexicons: string[], profiles: K8sProfiles, envs: string[], kubeconfig: Kubeconfig): DoctorCheck { + if (!lexicons.includes("k8s") && !lexicons.includes("helm")) { + return { name: "kube", status: "pass", detail: "no k8s or helm lexicon — no cluster binding to check" }; + } + const ambient = kubeconfig.currentContext; + if (!kubeconfig.contexts.size) { + return { + name: "kube", + status: "warn", + detail: "no kubeconfig readable (kubectl absent, or it has no contexts) — the declared graph still serves", + fix: "Install kubectl and point KUBECONFIG at your cluster, or run `behold demo k8s` for a throwaway k3d one.", + }; + } + const bound = envs.filter((e) => profiles[e]?.context); + if (!bound.length) { + return { + name: "kube", + status: "warn", + detail: `no k8s.profiles binding — chant falls back to the ambient current-context${ambient ? ` (${ambient})` : " (none set)"}`, + fix: "Declare `k8s.profiles..context` in chant.config.ts so the cluster the reads hit is explicit, not ambient.", + }; + } + const lines: string[] = []; + const unresolved: string[] = []; + const mismatched: string[] = []; + for (const env of bound) { + const context = profiles[env]!.context!; + const target = resolveK8sTarget(profiles, env, kubeconfig); + if (!target) { + unresolved.push(`${env}: ${context}`); + continue; + } + lines.push(`${env}: ${target.label} at ${target.endpoint}`); + if (ambient && ambient !== target.label) mismatched.push(`${env}: ${target.label}`); + } + if (unresolved.length) { + return { + name: "kube", + status: "warn", + detail: `context not in the kubeconfig — ${list(unresolved)}${lines.length ? `; bound: ${list(lines)}` : ""}`, + fix: `Add the context to your kubeconfig, or point k8s.profiles at one you have (${summarize([...kubeconfig.contexts.keys()])}).`, + }; + } + if (mismatched.length) { + return { + name: "kube", + status: "warn", + detail: `bound ${list(lines)}, but the ambient current-context is ${ambient} — chant refuses a live read on a mismatch`, + fix: `Run \`kubectl config use-context ${mismatched[0]!.split(": ")[1]}\` before the live overlay.`, + }; + } + return { name: "kube", status: "pass", detail: `bound ${list(lines)}` }; +} + +/** The substrates line: exactly what `/api/substrates` would report, without + * starting a server. Never a fail — a down emulator costs the live half, not + * the graph. */ +function substrateCheck(substrates: Substrate[]): DoctorCheck { + if (!substrates.length) return { name: "substrates", status: "pass", detail: "none needed by this project" }; + const detail = list(substrates.map((s) => `${s.label} ${s.status}`)); + const down = substrates.filter((s) => s.status === "down" || s.status === "blocked"); + if (!down.length) return { name: "substrates", status: "pass", detail }; + const named = down.map((s) => `${s.label} (${s.bringUp ? `${s.bringUp.cmd} ${s.bringUp.args.join(" ")}` : s.detail})`); + return { + name: "substrates", + status: "warn", + detail, + fix: `Bring up: ${list(named)} — or \`behold serve --local\`, which boots the project's own emulators (needs Docker).`, + }; +} + +/** The ops line: the committed Ops behold can trigger, and whether chant's MCP + * (where the mutating tools live) is reachable at all — it is the project's own + * chant that serves it, so a missing install takes the act loop with it. */ +function opsCheck(root: string, ops: OpInfo[], chantSource: ChantResolution["source"], estate: boolean): DoctorCheck { + const mcp = + chantSource === "project" + ? "chant MCP available (the project's own chant)" + : "chant MCP unavailable until the project's chant is installed"; + if (!ops.length) { + return { + name: "ops", + status: "warn", + detail: `no committed Ops (*.op.ts) — behold can show drift but has nothing to trigger; ${mcp}`, + fix: "Add an ApplyOp (code to cloud) or ReconcileOp (cloud to PR) under ops/ — behold triggers your Ops, it holds no apply creds.", + }; + } + const names = ops.map((o) => `${labelFor(root, o.dir, estate)}${o.name} (${o.kind})`); + return { name: "ops", status: "pass", detail: `${ops.length} committed: ${list(names)}; ${mcp}` }; +} + +/** + * Diagnose a directory. Read-only; resolves every fact through the module the + * server reads it from. A directory that is neither a chant project nor an + * estate root returns the single `project` fail — every later line would be a + * report about nothing. + */ +export async function diagnose(dir: string, probes: DoctorProbes = {}): Promise { + const root = resolve(dir); + const shape = detectProjectShape(root); + const behold = beholdVersion(); + + if (shape.kind === "none") { + return { + behold, + dir: root, + kind: "none", + ok: false, + checks: [ + { + name: "project", + status: "fail", + detail: `no chant.config.ts here, and no estate members declared (.behold.json \`members\`, or npm workspaces)`, + fix: "Point behold at a chant project (`behold doctor `), or run `behold demo` for a bundled working example.", + }, + ], + }; + } + + const estate = shape.kind === "estate"; + const members = (shape.members ?? []).map((m) => resolve(root, m)); + const targets = estate ? members : [root]; + const primary = targets[0]!; + + const projectCheck: DoctorCheck = estate + ? { + name: "project", + status: "pass", + detail: `estate of ${members.length} projects (${shape.membersFrom === "behold-config" ? ".behold.json members" : "npm workspaces"}): ${list( + members.map((m) => relative(root, m)), + )}`, + } + : { name: "project", status: "pass", detail: `chant project (${relative(root, shape.configFile!)})` }; + + // One config read per target, shared by the lexicon/env/kube lines — the + // same `detectProject` the server's pickers are built from. + const infos = await Promise.all(targets.map(async (t) => ({ target: t, info: await detectProject(t) }))); + const declared = new Map(infos.map(({ target, info }) => [target, info.lexicons] as const)); + const lexicons = [...new Set(infos.flatMap(({ info }) => info.lexicons))]; + const envs = [...new Set(infos.flatMap(({ info }) => info.environments))]; + const profiles: K8sProfiles = {}; + for (const { info } of infos) for (const [env, p] of Object.entries(info.k8sProfiles ?? {})) profiles[env] ??= p; + + const kubeconfig = lexicons.includes("k8s") || lexicons.includes("helm") ? await (probes.loadKubeconfig ?? loadKubeconfig)() : EMPTY_KUBECONFIG; + const kube = kubeCheck(lexicons, profiles, envs, kubeconfig); + // The bound context threads into the substrate probe exactly as it does in + // the server's /api/substrates route — so the Helm pill names the project's + // cluster, not whatever the operator's shell last pointed at. + const boundContext = resolveK8sTarget(profiles, envs[0], kubeconfig)?.label; + const substrates = await (probes.detectSubstrates ?? detectSubstrates)(primary, false, boundContext); + + const checks: DoctorCheck[] = [ + projectCheck, + chantCheck(root, targets, estate), + lexiconCheck(root, declared, estate), + // An estate root is not itself servable — the hint has to name its members + // (`behold serve a b c`, #31), which is what a stranger would otherwise + // discover by having the root serve nothing. + envCheck(envs, estate ? shape.members!.map((m) => `${dir.replace(/\/$/, "")}/${m}`).join(" ") : dir), + kube, + substrateCheck(substrates), + opsCheck(root, discoverEstateOps(targets), resolveChant(primary).source, estate), + ]; + + return { behold, dir: root, kind: shape.kind, ok: !checks.some((c) => c.status === "fail"), checks }; +} + +/** Render a report for a terminal. Plain text, no colour: the output is meant + * to be pasted into an issue as readily as read. */ +export function formatReport(report: DoctorReport): string { + const lines = [`behold doctor ${report.behold} — ${report.dir}`, ""]; + for (const c of report.checks) { + lines.push(` ${c.status.padEnd(4)} ${c.name.padEnd(10)} ${c.detail}`); + if (c.fix) lines.push(` ${"fix".padEnd(10)} ${c.fix}`); + } + const tally = (s: CheckStatus): number => report.checks.filter((c) => c.status === s).length; + lines.push("", ` ${tally("pass")} pass, ${tally("warn")} warn, ${tally("fail")} fail`, ""); + return lines.join("\n"); +} diff --git a/src/project.test.ts b/src/project.test.ts index 34faf66..7ef3510 100644 --- a/src/project.test.ts +++ b/src/project.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { detectProject, loadBeholdConfig } from "./project.ts"; -import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { detectProject, detectProjectShape, loadBeholdConfig } from "./project.ts"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -207,4 +207,60 @@ describe("loadBeholdConfig", () => { const dir = make(JSON.stringify({ tiers: { envVar: "LOOM_TIER", values: ["light", 7, "production"] } })); expect(loadBeholdConfig(dir)).toEqual({ tiers: { envVar: "LOOM_TIER", values: ["light", "production"] } }); }); + + it("reads estate members (#236) alongside tiers, and keeps only string entries", () => { + expect(loadBeholdConfig(make(JSON.stringify({ members: ["a", 7, "b"] })))).toEqual({ members: ["a", "b"] }); + expect(loadBeholdConfig(make(JSON.stringify({ members: [] })))).toEqual({}); + }); +}); + +/** #236: the shape the doctor's first line and the CLI's startup warning both + * read. Sync, so these build their own fixture dirs. */ +describe("detectProjectShape", () => { + const dirs: string[] = []; + afterAll(() => dirs.forEach((d) => rmSync(d, { recursive: true, force: true }))); + const make = (files: Record): string => { + const dir = mkdtempSync(join(tmpdir(), "behold-shape-")); + dirs.push(dir); + for (const [rel, content] of Object.entries(files)) { + mkdirSync(dirname(join(dir, rel)), { recursive: true }); + writeFileSync(join(dir, rel), content); + } + return dir; + }; + + it("is a project when a chant config is present, whatever else the directory holds", () => { + const dir = make({ "chant.config.ts": "export default {};", "package.json": JSON.stringify({ workspaces: ["a"] }) }); + expect(detectProjectShape(dir)).toEqual({ kind: "project", configFile: join(dir, "chant.config.ts") }); + }); + + it("reads the other config filenames chant accepts", () => { + expect(detectProjectShape(make({ "chant.config.mjs": "export default {};" })).kind).toBe("project"); + }); + + it("is an estate when .behold.json names member projects", () => { + const dir = make({ + ".behold.json": JSON.stringify({ members: ["a", "b"] }), + "a/chant.config.ts": "export default {};", + "b/chant.config.ts": "export default {};", + }); + expect(detectProjectShape(dir)).toEqual({ kind: "estate", members: ["a", "b"], membersFrom: "behold-config" }); + }); + + it("falls back to npm workspaces, keeping only the members that are chant projects", () => { + const dir = make({ + "package.json": JSON.stringify({ workspaces: ["cp", "tooling"] }), + "cp/chant.config.ts": "export default {};", + "tooling/package.json": "{}", + }); + expect(detectProjectShape(dir)).toEqual({ kind: "estate", members: ["cp"], membersFrom: "workspaces" }); + }); + + it("is none for a directory that is neither — #193's dead end", () => { + expect(detectProjectShape(make({ "notes.txt": "" }))).toEqual({ kind: "none" }); + // A workspaces root whose members are not chant projects is not an estate. + expect(detectProjectShape(make({ "package.json": JSON.stringify({ workspaces: ["x"] }), "x/package.json": "{}" }))).toEqual({ + kind: "none", + }); + }); }); diff --git a/src/project.ts b/src/project.ts index 0b4b281..46f1a2d 100644 --- a/src/project.ts +++ b/src/project.ts @@ -66,12 +66,22 @@ export interface TierConfig { * axis. Absent `tiers` means the project declares none. */ export interface BeholdConfig { tiers?: TierConfig; + /** Estate members (#236): the member project directories, relative to the + * root that declares them. An estate root is not itself a chant project — + * it's the directory you'd run `behold serve a b c` from — so this is how a + * root says which projects compose it without behold guessing. Absent when + * the file declares none (see `detectProjectShape`, which then falls back to + * npm `workspaces`). */ + members?: string[]; } const CONFIG_NAMES = ["chant.config.ts", "chant.config.mts", "chant.config.js", "chant.config.mjs"]; const BEHOLD_CONFIG_NAME = ".behold.json"; -function configPath(projectDir: string): string | undefined { +/** The project's chant config file, or undefined when the directory has none — + * the single "is this a chant project" test, shared by `detectProject`, + * `detectProjectShape` and the CLI's startup warning so all three agree. */ +export function chantConfigPath(projectDir: string): string | undefined { for (const name of CONFIG_NAMES) { const p = join(projectDir, name); if (existsSync(p)) return p; @@ -205,7 +215,7 @@ function parseStringLiteral(content: string, key: string): string | undefined { /** Detect what the project offers. Async: it may import the project's config. */ export async function detectProject(projectDir: string): Promise { - const path = configPath(projectDir); + const path = chantConfigPath(projectDir); if (!path) return { environments: [], lexicons: [] }; // Authoritative: run the real config. @@ -259,8 +269,58 @@ export function loadBeholdConfig(projectDir: string): BeholdConfig { try { const raw = JSON.parse(readFileSync(path, "utf8")) as Record; const tiers = readTiers(raw); - return tiers ? { tiers } : {}; + const members = Array.isArray(raw.members) ? raw.members.filter((m): m is string => typeof m === "string" && !!m) : []; + return { ...(tiers ? { tiers } : {}), ...(members.length ? { members } : {}) }; } catch { return {}; } } + +/** What kind of thing a directory is, as far as behold is concerned (#236): + * - `project` — a chant project: it has a `chant.config.*`. + * - `estate` — not itself a project, but it names member projects that are: + * `.behold.json`'s `members`, else npm `workspaces`. This is the + * `behold serve a b c` shape (#31) with the member list written + * down — `behold demo flux-estate` is one. + * - `none` — neither, which is #193's structured dead end. */ +export type ProjectKind = "project" | "estate" | "none"; + +export interface ProjectShape { + kind: ProjectKind; + /** The `chant.config.*` path, for `kind: "project"`. */ + configFile?: string; + /** Member directories relative to `dir`, for `kind: "estate"` — only those + * that are themselves chant projects, in declared order. */ + members?: string[]; + /** Where the member list came from, so a report never implies behold chose it. */ + membersFrom?: "behold-config" | "workspaces"; +} + +/** Read npm `workspaces` from a root package.json — the array form only (the + * `{ packages: [...] }` object form too). Globs are not expanded: a member + * entry has to name a directory, which is what an estate root writes. */ +function readWorkspaces(projectDir: string): string[] { + try { + const raw = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8")) as { + workspaces?: unknown; + }; + const ws = Array.isArray(raw.workspaces) ? raw.workspaces : (raw.workspaces as { packages?: unknown })?.packages; + return Array.isArray(ws) ? ws.filter((w): w is string => typeof w === "string") : []; + } catch { + return []; + } +} + +/** Classify a directory (#236's first doctor line, and the CLI's startup + * warning): a chant project, an estate root naming member projects, or + * neither. Sync and read-only — `existsSync` plus two JSON reads, no config + * import (that's `detectProject`, which needs the members resolved first). */ +export function detectProjectShape(projectDir: string): ProjectShape { + const configFile = chantConfigPath(projectDir); + if (configFile) return { kind: "project", configFile }; + const declared = loadBeholdConfig(projectDir).members; + const from: ProjectShape["membersFrom"] = declared ? "behold-config" : "workspaces"; + const members = (declared ?? readWorkspaces(projectDir)).filter((m) => !!chantConfigPath(join(projectDir, m))); + if (members.length) return { kind: "estate", members, membersFrom: from }; + return { kind: "none" }; +}