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" }; +}