diff --git a/docs/cli.md b/docs/cli.md index 1bf3908e1c..1f3246e7ff 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -574,6 +574,52 @@ without response bodies, or without the request bodies its writes need. See rea environment-compare manifest.json --evidence capture.json --probe probe.json --json ``` +## Report version and runtime + +`rea --version` prints the package version. Add an output format to get the +version with the runtime and platform context that a support request needs: + +Version reports do not initialize analysis providers. Invalid global settings +or output options still report their usual errors. + +```bash +rea --version --json +``` + +```json +{ + "name": "rea-agents", + "version": "6.7.0", + "runtime": { "id": "node", "version": "24.22.1" }, + "platform": "linux", + "architecture": "x64", + "supported_runtime": { + "range": "^22.23.4 || ^24.22.1 || >=26.12.1", + "satisfied": true + } +} +``` + +| Field | Meaning | +| ------------------------------------ | -------------------------------------------------------------------------------------------------------------- | +| `name`, `version` | Published package name and version; `version` matches plain `--version`. | +| `runtime.id` | `node` or `bun`. | +| `runtime.version` | Version of that runtime. | +| `runtime.node_compatibility_version` | Bun only: the Node.js version it reports compatibility with. | +| `platform`, `architecture` | Node.js `process.platform` and `process.arch`, such as `linux` and `x64`. | +| `supported_runtime.range` | Supported Node.js range. | +| `supported_runtime.satisfied` | Whether this runtime is supported, judged as `rea doctor` judges it; Bun by its Node.js compatibility version. | + +The field names are stable. The report contains no paths, environment values, +user or host names, and it starts no provider and reads no target. `--format` +selects any CLI output format, such as `--format yaml`. JSON follows the usual +rule: indented on a terminal, compact when piped or redirected. `--help` still +takes precedence. With other output controls, `--version` keeps its existing +behavior: valid controls such as `--filter-output` or `--full-output` print the +plain version, and invalid combinations report the usual output-flag error, +such as `UNSUPPORTED_OUTPUT_COMBINATION` for `--json` with `--token-limit`. Use +`rea doctor` for provider and host readiness. + ## Output and exit status `rea mcp doctor --json` checks production MCP startup and reports child logs in diff --git a/docs/verification/cli-version-report-2026-10-23.md b/docs/verification/cli-version-report-2026-10-23.md new file mode 100644 index 0000000000..e8a64c5089 --- /dev/null +++ b/docs/verification/cli-version-report-2026-10-23.md @@ -0,0 +1,120 @@ +# CLI version report verification — 2026-10-23 + +`rea --version --json` reports package/runtime/platform facts without loading +analysis command implementations. The shared CLI preflight retains configuration +and output-option error precedence. No cache was added: the unnecessary work was +loading the full command graph, and runtime facts must remain current. + +## Scope and identities + +- Real application: REA 6.7.0's built public CLI, on Linux x64; Node 24.22.1 and + npm 11.16.0 (repository pins). No target or analysis provider is required. +- Host CPU: AMD EPYC 7B12; affinity is restricted to two logical CPUs. + Node executable SHA-256: `e32e2d16081cf644f88392934288086e7662529d0d43277c34f7aad2207cfafb`. +- Baseline: `5655fbf17`, combining main `60accad2d` with original PR #6530 head + `044bd861b2df082eca5d4ad91d7cb79876c13430`. It already has the formatted report; + the comparison measures delivery of equivalent facts, not plain versus JSON. +- Original author commits are preserved. The optimized source is this PR's + launcher/preflight change; source hashes below bind the measured code. +- Both lanes use the same locked dependencies, executable and environment + controls. Package manifest SHA-256: `2218e615dc1e68ca31c02ee5f6f2dee1e2cf7aa6a78c1c22ac090e27fe49d2eb`. +- Built tree digests: baseline `a4bc8722b69046c5ace1d3e764bba81812427766f088418c27c6876812796722` + (2147 files); optimized `9919d1b6177536f75fd1acb9240525e82cb5af5a55f4db07cbcc4b243a5f8eb4` + (2153 files). Each digest hashes sorted relative + paths, a NUL separator, and each file's binary SHA-256. + +```text +47eae72df85b722dcabab6860980a7285886a854daaec74b47490b41f9652154 scripts/rea.mjs +73e5f50400b11012ccf6c6c312e1f91e027e92c4a5616f112bf97ea135e3253d src/cliOutput.ts +64d82457c9f9f276379f9734caff181117afc3fd17fa50d90140a92853395f43 src/cliConfiguration.ts +fa221460cf337bb2463bd47cd919066a612f369ab866cc1dfbf27ae49259e182 src/domain/versionReport.ts +``` + +## Completed public tasks and resource measurements + +Commands, each in a fresh process from each checkout: + +```sh +node scripts/rea.mjs --version --json +node scripts/rea.mjs --version +node scripts/rea.mjs providers --json --filter-output 'capabilities[0,1].provider.id,open' +``` + +Three alternating pairs per task, reversing order in pair two. All 18 completed +with exit 0 and byte-identical stdout within each task. These are warm build/file +cache measurements; no OS-cold run or in-process warm session was measured. +`NODE_DISABLE_COMPILE_CACHE=1` disables Node's compile cache in both lanes. +CPU affinity is 0–1; Node heap limit is 2048 MiB; `GOMAXPROCS=2`, +`GOMEMLIMIT=2GiB`, and `UV_THREADPOOL_SIZE=2`. A sampled 6 GiB early-stop guard +was never reached; it is not a kernel memory limit. + +Elapsed includes process startup through exit. CPU is wait-accounted child CPU. +Peak RSS is the maximum sampled sum across the payload process family, every +50 ms; shared pages can be counted more than once and brief peaks can be missed. +The external sampler is excluded. All measured commands here have no analysis +engine child. Other host work is uncontrolled, so small control differences do +not establish an improvement. + +Medians; paired values are **before / after**: + +| Task | Elapsed (s) | CPU (s) | Peak family RSS (MiB) | Stdout bytes | +| -------------------------- | ------------: | ------------: | --------------------: | -----------: | +| Formatted version | 1.716 / 0.203 | 1.968 / 0.198 | 335.6 / 72.1 | 207 | +| Plain version control | 1.876 / 1.783 | 2.221 / 1.990 | 346.8 / 338.9 | 6 | +| Filtered providers control | 2.467 / 2.182 | 2.624 / 2.461 | 384.7 / 378.2 | 67 | + +First measured formatted-version process, separated from the repeat medians: + +| Lane | Elapsed (s) | CPU (s) | Peak family RSS (MiB) | +| ------ | ----------: | ------: | --------------------: | +| before | 1.716 | 1.968 | 335.6 | +| after | 0.203 | 0.198 | 72.1 | + +The JSON payload remains 207 bytes with all documented fields. No token +reduction is claimed. Token counts, host prompt handling, and actual model usage +were not measured; these CLI tasks have no MCP discovery or follow-up calls. +Earlier development probes, including probes overlapping focused tests, are +excluded from this final comparison. No general analysis/provider speedup is +claimed. + +## Correctness and compatibility + +- 231 tests passed across the 12 exact selected files below, serially. + This covers runtime/domain contracts, actual CLI output and error handling, + dispatcher cleanup, runtime bundling, real MCP doctor catalogs, doctor + composition, and both compiled CLI test drivers. +- The new dispatcher regression uses the real built preflight and CLI modules + that throw on import. JSON succeeds without those imports; invalid output and + configuration retain their errors. Restoring the original launcher makes this + regression fail specifically on the blocked analysis import (negative control). +- A real PTY invocation returned indented JSON matching piped facts. Plain + version, YAML and help precedence remain covered through the public CLI. +- A real Node 20.20.2 invocation returned its version with + `supported_runtime.satisfied: false`; this does not establish analysis support + for Node 20. Bun, Windows and macOS end-to-end behavior remain unverified. +- An earlier parallel 210-test selection had 209 passes and one existing + `cliOutput` case exceeding its 3000 ms process deadline. That failed attempt is + retained; the subsequent serial 231-test selection passed without relaxing any + deadline. Its cause is not conclusively attributed. The filtered-providers + benchmark above exercises that same public command as a compatibility control. +- `npm run check` passed after declaring the existing `cliOutput` runtime + entrypoint to Knip. This keeps the actual dynamically consumed entry visible + rather than ignoring the new preflight export. +- `npm run docs:check` passed, including 9 README consistency tests. + +The serial selection used already built prerequisites: + +```sh +node scripts/run-exact-tests.mjs run --maxWorkers=1 --no-file-parallelism \ + src/domain/runtimeVersion.test.ts src/domain/versionReport.test.ts \ + tests/boundary/cli/cliOutput.test.ts tests/boundary/cli/versionReportCli.test.ts \ + tests/boundary/cli/dispatcher.test.ts tests/boundary/process/runtimeBundle.test.ts \ + tests/boundary/mcp/mcpDoctor.test.ts tests/boundary/cli/mcpDoctorHelpCli.test.ts \ + src/doctorRuntime.test.ts src/application/Doctor.test.ts \ + tests/boundary/cli/compiledCliSession.test.ts \ + tests/boundary/cli/sharedJavaScriptCliSession.test.ts +``` + +This workflow reports runtime metadata, not target-analysis Evidence. Existing +MCP server identity remains the MCP version surface; no duplicate tool or schema +was introduced. diff --git a/knip.json b/knip.json index 550b351717..79472a53c5 100644 --- a/knip.json +++ b/knip.json @@ -3,6 +3,7 @@ "entry": [ "src/main.ts", "src/cli.ts", + "src/cliOutput.ts", "src/composition/javascriptAnalysisWorkerProcess.ts", "src/composition/webBundleAnalysisWorkerProcess.ts", "src/contracts/errorSchemas.ts", diff --git a/scripts/rea.mjs b/scripts/rea.mjs index cde17dde0e..578d29d62b 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -81,21 +81,33 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { writeStdout(result.output); process.exitCode = result.exitCode; } else { - const { cliConfigurationError, closeCliJavaScriptResources, createCli } = - await import("../dist/cli-runtime/cli.js"); - const { createCliResultOutput } = - await import("../dist/cli-runtime/cliResultOutput.js"); const { + cliConfigurationError, normalizeCliArguments, renderCliOutputArgumentError, renderEmptyFilteredCliOutput, + renderVersionReport, validateCliOutputArguments, } = await import("../dist/cli-runtime/cliOutput.js"); guardClosedStdout(); const cliArgs = normalizeCliArguments(args); const outputArguments = validateCliOutputArguments(cliArgs); + // `--version` with an explicit format reports the runtime as a document; + // plain `--version` stays Incur's bare version string. + const versionOutput = renderVersionReport( + cliArgs, + { + packageName: packageJson.name, + packageVersion: packageJson.version, + versions: process.versions, + platform: process.platform, + architecture: process.arch, + }, + process.stdout.isTTY === true, + ); const configurationError = cliConfigurationError(process.env); let dispatchFailure; + let closeCliJavaScriptResources; // Incur can exit from the callback below before this function returns. // That callback owns the status, including a closed-pipe exit of 141. let exiting = false; @@ -108,7 +120,14 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { } else if (!outputArguments.ok) { writeStdout(renderCliOutputArgumentError(outputArguments)); process.exitCode = 1; + } else if (versionOutput !== undefined) { + writeStdout(versionOutput); } else { + const cliRuntime = await import("../dist/cli-runtime/cli.js"); + closeCliJavaScriptResources = cliRuntime.closeCliJavaScriptResources; + const { createCli } = cliRuntime; + const { createCliResultOutput } = + await import("../dist/cli-runtime/cliResultOutput.js"); let wroteOutput = false; const resultOutput = createCliResultOutput(cliArgs, process.stdout); try { @@ -171,7 +190,7 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { // Serve and output rendering can throw after a worker is parked. Stop it // on that path too, and keep the dispatch failure when both fail. try { - await closeCliJavaScriptResources(); + await closeCliJavaScriptResources?.(); } catch (cleanup) { // The exit callback reports this same shared failure and then exits. if (!exiting) { diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts index 270fcc00c9..3cbfc7e784 100644 --- a/src/application/DoctorDiagnostics.ts +++ b/src/application/DoctorDiagnostics.ts @@ -1,6 +1,6 @@ import { SUPPORTED_NODE_VERSION_PROSE, - supportsNodeVersion, + supportsJavaScriptRuntime, } from "../domain/runtimeVersion.js"; import { providerStartupTimeoutSchema } from "../config/providerStartupTimeout.js"; import { hopperLauncherDiscovery } from "./HopperLauncherDiscovery.js"; @@ -94,7 +94,7 @@ export const doctorHealthy = ( ); }; -/** Bun passes when its reported Node.js compatibility version is supported. */ +/** Runtime support follows the shared rule, which judges Bun by its Node.js compatibility. */ const nodeCheck = ({ runtime }: DoctorHost): DoctorCheck => { const failure = { remediation: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`, @@ -104,7 +104,7 @@ const nodeCheck = ({ runtime }: DoctorHost): DoctorCheck => { return { ...check( "node", - supportsNodeVersion(runtime.version), + supportsJavaScriptRuntime(runtime), runtime.version, failure, ), @@ -113,7 +113,7 @@ const nodeCheck = ({ runtime }: DoctorHost): DoctorCheck => { return { ...check( "node", - supportsNodeVersion(runtime.nodeCompatibilityVersion), + supportsJavaScriptRuntime(runtime), `Bun ${runtime.version} (Node.js ${runtime.nodeCompatibilityVersion} compatibility)`, failure, ), diff --git a/src/cli.ts b/src/cli.ts index 5a9706e941..701f3b9acd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,7 +24,6 @@ import { normalizeCliArguments } from "./cliOutput.js"; import { createLogger } from "./logger.js"; import { parseLogLevel } from "./config/processStart.js"; -import { projectAnalysisError } from "./domain/analysisErrorProjection.js"; import { PRODUCT_IDENTITY } from "./identity.js"; import { npxRegistrationCommand } from "./application/ClientRegistrationIdentity.js"; import { registerSetupCommands } from "./cli/setupCommands.js"; @@ -66,19 +65,6 @@ import { excludeDeclaredSensitiveArguments } from "./cli/explicitSensitiveArgume export { closeCliJavaScriptResources } from "./cli/javascriptApplicationAnalysis.js"; -/** - * Describe CLI settings that are invalid before any command runs, as MCP does. - * CLI, MCP and Doctor share the process-start configuration contract. - */ -export const cliConfigurationError = ( - selectedEnvironment: Readonly>, -): string | undefined => { - const value = selectedEnvironment.REA_LOG_LEVEL; - if (value === undefined) return undefined; - const level = parseLogLevel(value); - return level.ok ? undefined : projectAnalysisError(level.error).message; -}; - /** * Build the one-shot Incur CLI without starting Hopper at import time. * Analysis commands acquire and close their own sessions; bare `mcp` and diff --git a/src/cliConfiguration.ts b/src/cliConfiguration.ts new file mode 100644 index 0000000000..ad146116b6 --- /dev/null +++ b/src/cliConfiguration.ts @@ -0,0 +1,15 @@ +import { parseLogLevel } from "./config/processStart.js"; +import { projectAnalysisError } from "./domain/analysisErrorProjection.js"; + +/** + * Describe invalid CLI settings before loading command implementations. + * CLI, MCP and Doctor share the process-start configuration contract. + */ +export const cliConfigurationError = ( + selectedEnvironment: Readonly>, +): string | undefined => { + const value = selectedEnvironment.REA_LOG_LEVEL; + if (value === undefined) return undefined; + const level = parseLogLevel(value); + return level.ok ? undefined : projectAnalysisError(level.error).message; +}; diff --git a/src/cliOutput.ts b/src/cliOutput.ts index ea2fba6f6a..1a7dbc5eab 100644 --- a/src/cliOutput.ts +++ b/src/cliOutput.ts @@ -1,8 +1,44 @@ +import { Formatter } from "incur"; + +import { jsonOutputParts } from "./cli/jsonOutput.js"; +import { + versionReport, + type VersionReportHost, +} from "./domain/versionReport.js"; + +// The launcher and compiled CLI drivers share this lightweight entrypoint. +export { cliConfigurationError } from "./cliConfiguration.js"; + const UNSUPPORTED_OUTPUT_COMBINATION_MESSAGE = "Token windows cannot preserve structured output. Remove --token-limit/--token-offset and use --filter-output or command pagination."; type StructuredOutputFormat = "json" | "jsonl" | "yaml"; -const INCUR_OUTPUT_FORMATS = new Set(["toon", "json", "yaml", "md", "jsonl"]); +/** Output formats Incur renders, in its documented order. */ +export const CLI_OUTPUT_FORMATS = [ + "toon", + "json", + "yaml", + "md", + "jsonl", +] as const; +export type CliOutputFormat = (typeof CLI_OUTPUT_FORMATS)[number]; +const INCUR_OUTPUT_FORMATS: ReadonlySet = new Set(CLI_OUTPUT_FORMATS); + +const isCliOutputFormat = (value: string): value is CliOutputFormat => + INCUR_OUTPUT_FORMATS.has(value); + +/** + * Render a document REA writes outside Incur's command pipeline. JSON follows + * the CLI contract: indented for a terminal, compact when piped or redirected. + */ +export const renderCliDocument = ( + value: unknown, + format: CliOutputFormat, + stdoutIsTerminal: boolean, +): string => + format === "json" || format === "jsonl" + ? [...jsonOutputParts(value, format, stdoutIsTerminal)].join("") + : `${Formatter.format(value, format)}\n`; /** Global output flags that take a value. */ const VALUED_OUTPUT_FLAGS = new Set([ "--format", @@ -145,6 +181,8 @@ export type CliOutputArgumentValidation = interface ParsedCliOutputArguments { readonly filterOutput: string | undefined; readonly format: string; + /** Whether `--json` or `--format` selected the format, rather than the default. */ + readonly formatExplicit: boolean; readonly fullOutput: boolean; /** First valued output flag that Incur skips because it has no value. */ readonly missingValue: string | undefined; @@ -160,6 +198,7 @@ export const parseCliOutputArguments = ( arguments_: readonly string[], ): ParsedCliOutputArguments => { let format: string = "toon"; + let formatExplicit = false; let filterOutput: string | undefined; let fullOutput = false; let missingValue: string | undefined; @@ -169,6 +208,7 @@ export const parseCliOutputArguments = ( const parsed = (parseError: boolean): ParsedCliOutputArguments => ({ filterOutput, format, + formatExplicit, fullOutput, missingValue, parseError, @@ -184,6 +224,7 @@ export const parseCliOutputArguments = ( // normalizeCliArguments has produced. Mirror those effective values. if (argument === "--json") { format = "json"; + formatExplicit = true; continue; } if (argument === "--format") { @@ -194,6 +235,7 @@ export const parseCliOutputArguments = ( if (cliOutputValueRejection(argument, value) !== undefined) return parsed(true); format = value; + formatExplicit = true; index += 1; } else missingValue ??= argument; continue; @@ -309,3 +351,50 @@ export const renderCliOutputArgumentError = ( return `ok: false\nerror:\n code: ${error.code}\n message: ${JSON.stringify(error.message)}\n`; return `${JSON.stringify(value)}\n`; }; + +/** + * The format of a root `--version` request that selects one explicitly, or + * undefined when Incur should answer it. Only `--version`, `--json` and + * `--format ` may appear, so help keeps precedence, a command keeps + * its own `--version` option, and other output controls keep Incur's plain + * version. Plain `--version` stays the bare version string. + */ +export const versionReportFormat = ( + arguments_: readonly string[], +): CliOutputFormat | undefined => { + let version = false; + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + if (argument === "--version") { + const next = arguments_[index + 1]; + // A following value belongs to a command-local `--version` option. + if (next !== undefined && !next.startsWith("-")) return undefined; + version = true; + } else if (argument === "--format") index += 1; + else if (argument !== "--json") return undefined; + } + if (!version) return undefined; + const { format, formatExplicit, missingValue, parseError } = + parseCliOutputArguments(arguments_); + return formatExplicit && + !parseError && + missingValue === undefined && + isCliOutputFormat(format) + ? format + : undefined; +}; + +/** + * Render the version report for a formatted root `--version` request, or + * return undefined so Incur prints the bare version or handles the request. + */ +export const renderVersionReport = ( + arguments_: readonly string[], + host: VersionReportHost, + stdoutIsTerminal: boolean, +): string | undefined => { + const format = versionReportFormat(arguments_); + return format === undefined + ? undefined + : renderCliDocument(versionReport(host), format, stdoutIsTerminal); +}; diff --git a/src/domain/runtimeVersion.test.ts b/src/domain/runtimeVersion.test.ts index 2d22745ed3..5828a410a9 100644 --- a/src/domain/runtimeVersion.test.ts +++ b/src/domain/runtimeVersion.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { javaScriptRuntime, supportsNodeVersion } from "./runtimeVersion.js"; +import { + javaScriptRuntime, + supportsJavaScriptRuntime, + supportsNodeVersion, +} from "./runtimeVersion.js"; describe("supported Node.js versions", () => { it.each([ @@ -43,3 +47,21 @@ describe("JavaScript runtime identity", () => { }); }); }); + +describe("supported JavaScript runtimes", () => { + it.each([ + [{ id: "node", version: "24.22.1" }, true], + [{ id: "node", version: "24.18.0" }, false], + // Bun is judged by its Node.js compatibility version, not its own. + [ + { id: "bun", version: "1.4.2", nodeCompatibilityVersion: "26.12.1" }, + true, + ], + [ + { id: "bun", version: "99.0.0", nodeCompatibilityVersion: "24.18.0" }, + false, + ], + ] as const)("classifies %o as supported=%s", (runtime, expected) => { + expect(supportsJavaScriptRuntime(runtime)).toBe(expected); + }); +}); diff --git a/src/domain/runtimeVersion.ts b/src/domain/runtimeVersion.ts index 7852e6c4f1..e6a7e509ca 100644 --- a/src/domain/runtimeVersion.ts +++ b/src/domain/runtimeVersion.ts @@ -32,3 +32,11 @@ export const javaScriptRuntime = (versions: { version: versions.bun, nodeCompatibilityVersion: versions.node, }; + +/** Bun is supported when its reported Node.js compatibility version is. */ +export const supportsJavaScriptRuntime = ( + runtime: JavaScriptRuntime, +): boolean => + supportsNodeVersion( + runtime.id === "node" ? runtime.version : runtime.nodeCompatibilityVersion, + ); diff --git a/src/domain/versionReport.test.ts b/src/domain/versionReport.test.ts new file mode 100644 index 0000000000..27e0fe6b38 --- /dev/null +++ b/src/domain/versionReport.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { SUPPORTED_NODE_VERSION_RANGE } from "./runtimeVersion.js"; +import { versionReport, versionReportSchema } from "./versionReport.js"; + +const host = { + packageName: "rea-agents", + packageVersion: "6.7.0", + platform: "linux", + architecture: "x64", +} as const; + +describe("version report", () => { + it("reports the package, Node.js runtime, platform and support", () => { + const report = versionReport({ ...host, versions: { node: "24.22.1" } }); + + expect(versionReportSchema.parse(report)).toEqual({ + name: "rea-agents", + version: "6.7.0", + runtime: { id: "node", version: "24.22.1" }, + platform: "linux", + architecture: "x64", + supported_runtime: { + range: SUPPORTED_NODE_VERSION_RANGE, + satisfied: true, + }, + }); + }); + + it("judges Bun by its Node.js compatibility version", () => { + const report = versionReport({ + ...host, + versions: { node: "24.18.0", bun: "1.4.2" }, + }); + + expect(versionReportSchema.parse(report)).toMatchObject({ + runtime: { + id: "bun", + version: "1.4.2", + node_compatibility_version: "24.18.0", + }, + supported_runtime: { satisfied: false }, + }); + }); + + it("rejects fields outside the documented schema", () => { + const report = versionReport({ ...host, versions: { node: "24.22.1" } }); + + expect( + versionReportSchema.safeParse({ ...report, home: "/home/user" }).success, + ).toBe(false); + }); +}); diff --git a/src/domain/versionReport.ts b/src/domain/versionReport.ts new file mode 100644 index 0000000000..3fb4bcccfb --- /dev/null +++ b/src/domain/versionReport.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +import { + javaScriptRuntime, + SUPPORTED_NODE_VERSION_RANGE, + supportsJavaScriptRuntime, +} from "./runtimeVersion.js"; + +/** + * Machine-readable `rea --version` report for support requests. Every field + * comes from package metadata or the runtime's own identity; it never carries + * a path, an environment value, or a user or host name. + */ +export const versionReportSchema = z.strictObject({ + name: z.string(), + version: z.string(), + runtime: z.discriminatedUnion("id", [ + z.strictObject({ id: z.literal("node"), version: z.string() }), + z.strictObject({ + id: z.literal("bun"), + version: z.string(), + node_compatibility_version: z.string(), + }), + ]), + platform: z.string(), + architecture: z.string(), + supported_runtime: z.strictObject({ + range: z.string(), + satisfied: z.boolean(), + }), +}); + +export type VersionReport = z.infer; + +/** Facts the report is built from, injected so it is independent of globals. */ +export interface VersionReportHost { + readonly packageName: string; + readonly packageVersion: string; + readonly versions: { readonly node: string; readonly bun?: string }; + readonly platform: string; + readonly architecture: string; +} + +/** Build the report; runtime support uses the same rule as `rea doctor`. */ +export const versionReport = (host: VersionReportHost): VersionReport => { + const runtime = javaScriptRuntime(host.versions); + return { + name: host.packageName, + version: host.packageVersion, + runtime: + runtime.id === "node" + ? { id: "node", version: runtime.version } + : { + id: "bun", + version: runtime.version, + node_compatibility_version: runtime.nodeCompatibilityVersion, + }, + platform: host.platform, + architecture: host.architecture, + supported_runtime: { + range: SUPPORTED_NODE_VERSION_RANGE, + satisfied: supportsJavaScriptRuntime(runtime), + }, + }; +}; diff --git a/src/mcpDoctor.ts b/src/mcpDoctor.ts index 06678618dc..fa33332379 100644 --- a/src/mcpDoctor.ts +++ b/src/mcpDoctor.ts @@ -1,13 +1,17 @@ import { Client, SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { Formatter, Help, z } from "incur"; +import { Help, z } from "incur"; import { performance } from "node:perf_hooks"; import { resolve } from "node:path"; import { StringDecoder } from "node:string_decoder"; import { createCatalogIdentity } from "./catalogIdentity.js"; -import { jsonOutputParts } from "./cli/jsonOutput.js"; -import { missingCliOutputValueMessage } from "./cliOutput.js"; +import { + CLI_OUTPUT_FORMATS, + type CliOutputFormat, + missingCliOutputValueMessage, + renderCliDocument, +} from "./cliOutput.js"; import { compactPresentationForMcpInputSchemaProfile, parseMcpInputSchemaProfile, @@ -26,8 +30,8 @@ import { mcpDoctorDeadlineRecovery, } from "./mcpStartupPolicy.js"; -const OUTPUT_FORMATS = ["toon", "json", "yaml", "md", "jsonl"] as const; -type OutputFormat = (typeof OUTPUT_FORMATS)[number]; +const OUTPUT_FORMATS = CLI_OUTPUT_FORMATS; +type OutputFormat = CliOutputFormat; /** * Canonical `rea mcp doctor` options. This schema is the single source of @@ -338,7 +342,7 @@ export const runProductionMcpDoctorCli = async ( if (parsed.help) return { output: `${renderMcpDoctorHelp()}\n`, exitCode: 0 }; return { - output: renderOutput( + output: renderCliDocument( { code: parsed.code, message: parsed.message }, parsed.format, input.stdoutIsTerminal, @@ -363,7 +367,11 @@ export const runProductionMcpDoctorCli = async ( } : result; return { - output: renderOutput(outputValue, parsed.format, input.stdoutIsTerminal), + output: renderCliDocument( + outputValue, + parsed.format, + input.stdoutIsTerminal, + ), exitCode: result.healthy ? 0 : 1, }; }; @@ -447,15 +455,6 @@ type McpDoctorArguments = readonly message: string; }; -const renderOutput = ( - value: unknown, - format: OutputFormat, - stdoutIsTerminal: boolean, -): string => - format === "json" || format === "jsonl" - ? [...jsonOutputParts(value, format, stdoutIsTerminal)].join("") - : `${Formatter.format(value, format)}\n`; - const parseOutputArguments = ( arguments_: readonly string[], ): McpDoctorArguments => { diff --git a/tests/boundary/cli/cliOutput.test.ts b/tests/boundary/cli/cliOutput.test.ts index 49ccb91378..fa9231a63c 100644 --- a/tests/boundary/cli/cliOutput.test.ts +++ b/tests/boundary/cli/cliOutput.test.ts @@ -10,7 +10,9 @@ import { normalizeCliArguments, renderCliOutputArgumentError, renderEmptyFilteredCliOutput, + renderVersionReport, validateCliOutputArguments, + versionReportFormat, } from "../../../src/cliOutput.js"; const CLI_INTEGRATION_TIMEOUT_MS = 60_000; @@ -593,3 +595,51 @@ describe("CLI update root alias", () => { }, ); }); + +describe("version report requests", () => { + it.each([ + [["--version", "--json"], "json"], + [["--json", "--version"], "json"], + [["--version", "--format", "yaml"], "yaml"], + [normalizeCliArguments(["--version", "--format=toon"]), "toon"], + // Without an explicit format, Incur prints the bare version. + [["--version"], undefined], + // Help keeps precedence over --version. + [["--version", "--json", "--help"], undefined], + [["-h", "--version", "--json"], undefined], + // A command keeps its own --version option. + [["providers", "--version", "--json"], undefined], + [["--version", "1", "--json"], undefined], + // Other output controls keep Incur's handling. + [["--version", "--json", "--filter-output", "version"], undefined], + [["--version", "--json", "--token-count"], undefined], + [["--version", "--format", "xml"], undefined], + [["--version", "--format"], undefined], + [["--json"], undefined], + ] as const)("reads %j as format %s", (arguments_, expected) => { + expect(versionReportFormat(arguments_)).toBe(expected); + }); +}); + +describe("version report rendering", () => { + const host = { + packageName: "rea-agents", + packageVersion: "6.7.0", + versions: { node: "24.22.1" }, + platform: "linux", + architecture: "x64", + }; + + it("indents JSON for a terminal and compacts it when piped", () => { + const terminal = renderVersionReport(["--version", "--json"], host, true); + const piped = renderVersionReport(["--version", "--json"], host, false); + + expect(terminal?.split("\n").length).toBeGreaterThan(2); + expect(piped?.trimEnd().split("\n")).toHaveLength(1); + expect(JSON.parse(terminal ?? "")).toEqual(JSON.parse(piped ?? "")); + }); + + it("leaves plain --version to Incur", () => { + expect(renderVersionReport(["--version"], host, true)).toBeUndefined(); + }); +}); diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts index 621c5d98fc..5e67cc37f6 100644 --- a/tests/boundary/cli/dispatcher.test.ts +++ b/tests/boundary/cli/dispatcher.test.ts @@ -1,5 +1,6 @@ import { copyFile } from "node:fs/promises"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, expect } from "vitest"; @@ -7,7 +8,7 @@ import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; import type { TestWorkspace } from "../../support/workspace/workspaceFixture.js"; const CLI_OUTPUT_RUNTIME = - "export const normalizeCliArguments = (args) => args; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined;\n"; + "export const cliConfigurationError = () => undefined; export const normalizeCliArguments = (args) => args; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined; export const renderVersionReport = () => undefined;\n"; const installCliRuntime = async ( workspace: TestWorkspace, @@ -28,13 +29,74 @@ const installCliRuntime = async ( ]); }; +describe("version report dispatch", () => { + workspaceCliTest( + "reports formatted versions without loading analysis commands and preserves preflight errors", + async ({ processes, workspace }) => { + const blocked = + 'throw new Error("analysis command runtime was loaded");\n'; + await installCliRuntime(workspace, blocked, blocked); + await workspace.write( + "package.json", + JSON.stringify({ + type: "module", + name: "rea-fixture", + version: "1.0.0", + }), + ); + // Use the built production preflight, while making full CLI imports fail. + await workspace.write( + "dist/cli-runtime/cliOutput.js", + `export * from ${JSON.stringify(pathToFileURL(resolve("dist/cli-runtime/cliOutput.js")).href)};\n`, + ); + const invoke = (arguments_: readonly string[], invalidLogLevel = false) => + processes.run( + process.execPath, + [workspace.path("scripts/rea.mjs"), ...arguments_], + { + cwd: workspace.root, + env: { + ...process.env, + REA_LOG_LEVEL: invalidLogLevel ? "invalid-level" : "silent", + }, + }, + ); + + const report = await invoke(["--version", "--json"]); + expect(report.exitCode, report.stderr).toBe(0); + expect(JSON.parse(report.stdout)).toMatchObject({ + name: "rea-fixture", + version: "1.0.0", + runtime: { id: "node", version: process.versions.node }, + }); + const invalidOutput = await invoke([ + "--version", + "--json", + "--token-limit", + "5", + ]); + expect(invalidOutput.exitCode, invalidOutput.stderr).toBe(1); + expect(JSON.parse(invalidOutput.stdout)).toMatchObject({ + error: { code: "UNSUPPORTED_OUTPUT_COMBINATION" }, + }); + const invalidConfiguration = await invoke(["--version", "--json"], true); + expect(invalidConfiguration.exitCode).toBe(1); + expect(invalidConfiguration.stdout).toBe(""); + expect(invalidConfiguration.stderr).toContain("REA_LOG_LEVEL"); + expect(invalidConfiguration.stderr).not.toContain( + "analysis command runtime was loaded", + ); + }, + ); +}); + describe("executable dispatcher", () => { workspaceCliTest( "does not append recovery output after delegated stdout has begun", async ({ processes, workspace }) => { await installCliRuntime( workspace, - 'export const cliConfigurationError = () => undefined; export const closeCliJavaScriptResources = () => {}; export const createCli = () => ({ serve: (_args, options) => { options.stdout("first document\\n"); throw new RangeError("Invalid string length"); } });\n', + 'export const closeCliJavaScriptResources = () => {}; export const createCli = () => ({ serve: (_args, options) => { options.stdout("first document\\n"); throw new RangeError("Invalid string length"); } });\n', 'export const createCliResultOutput = () => ({ handled: false, failed: false, recoverFormattingFailure: () => { process.stdout.write("unexpected second document"); return true; } });\n', ); const result = await processes.run( @@ -67,12 +129,9 @@ describe("executable dispatcher", () => { ), workspace.write( "dist/cli-runtime/cli.js", - 'export const cliConfigurationError = () => undefined; export const closeCliJavaScriptResources = () => {}; export const createCli = () => ({ serve: (args) => process.stdout.write(JSON.stringify({ adapter: "cli", args })) });\n', - ), - workspace.write( - "dist/cli-runtime/cliOutput.js", - "export const normalizeCliArguments = (args) => args; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined;\n", + 'export const closeCliJavaScriptResources = () => {}; export const createCli = () => ({ serve: (args) => process.stdout.write(JSON.stringify({ adapter: "cli", args })) });\n', ), + workspace.write("dist/cli-runtime/cliOutput.js", CLI_OUTPUT_RUNTIME), workspace.write( "dist/cli-runtime/cliResultOutput.js", "export const createCliResultOutput = () => undefined;\n", @@ -171,7 +230,7 @@ describe("executable dispatcher JavaScript shutdown", () => { async ({ processes, workspace }) => { await installCliRuntime( workspace, - 'export const cliConfigurationError = () => undefined; export const closeCliJavaScriptResources = () => Promise.reject(new Error("worker still owned")); export const createCli = () => ({ serve: (_args, options) => { options.exit(0); } });\n', + 'export const closeCliJavaScriptResources = () => Promise.reject(new Error("worker still owned")); export const createCli = () => ({ serve: (_args, options) => { options.exit(0); } });\n', ); const result = await processes.run( process.execPath, @@ -188,7 +247,7 @@ describe("executable dispatcher JavaScript shutdown", () => { async ({ processes, workspace }) => { await installCliRuntime( workspace, - 'export const cliConfigurationError = () => undefined; export const closeCliJavaScriptResources = () => Promise.reject(new Error("worker still owned")); export const createCli = () => ({ serve: (_args, options) => { process.stdout.emit("error", Object.assign(new Error("broken pipe"), { code: "EPIPE" })); options.exit(0); } });\n', + 'export const closeCliJavaScriptResources = () => Promise.reject(new Error("worker still owned")); export const createCli = () => ({ serve: (_args, options) => { process.stdout.emit("error", Object.assign(new Error("broken pipe"), { code: "EPIPE" })); options.exit(0); } });\n', ); const result = await processes.run( process.execPath, diff --git a/tests/boundary/cli/versionReportCli.test.ts b/tests/boundary/cli/versionReportCli.test.ts new file mode 100644 index 0000000000..f7cb5e3729 --- /dev/null +++ b/tests/boundary/cli/versionReportCli.test.ts @@ -0,0 +1,94 @@ +import { readFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; +import { z } from "zod"; + +import { versionReportSchema } from "../../../src/domain/versionReport.js"; +import { SUPPORTED_NODE_VERSION_RANGE } from "../../../src/domain/runtimeVersion.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const packageSchema = z.object({ name: z.string(), version: z.string() }); + +cliTest( + "reports version and runtime as a document only when a format is selected", + async ({ cli }) => { + const { name, version } = packageSchema.parse( + JSON.parse(await readFile("package.json", "utf8")), + ); + // Unique private values: the report must carry none of them. Literal + // report values such as "node" or "linux" cannot collide with these. + const marker = `rea-private-${randomUUID()}`; + const cwd = await createTestTempDirectory(`${marker}-cwd-`); + const home = join(cwd, `${marker}-home`); + await mkdir(home); + const environment = { + HOME: home, + USERPROFILE: home, + REA_VERSION_REPORT_PRIVATE_MARKER: marker, + }; + + const json = await cli.run({ + arguments: ["--version", "--json"], + cwd, + environment, + }); + expect(json.exitCode, json.stderr).toBe(0); + // Piped JSON is one compact document. + expect(json.stdout.trimEnd().split("\n")).toHaveLength(1); + const report = versionReportSchema.parse(JSON.parse(json.stdout)); + expect(report).toEqual({ + name, + version, + runtime: { id: "node", version: process.versions.node }, + platform: process.platform, + architecture: process.arch, + supported_runtime: { + range: SUPPORTED_NODE_VERSION_RANGE, + satisfied: true, + }, + }); + expect(json.stdout).not.toContain(marker); + + const yaml = await cli.run({ + arguments: ["--version", "--format=yaml"], + cwd, + }); + expect(yaml.exitCode, yaml.stderr).toBe(0); + expect(yaml.stdout).toContain(`version: ${version}`); + expect(yaml.stdout).toContain(`architecture: ${process.arch}`); + + // Plain --version keeps the bare version string. + const plain = await cli.run({ arguments: ["--version"], cwd }); + expect(plain.exitCode, plain.stderr).toBe(0); + expect(plain.stdout).toBe(`${version}\n`); + + // Valid other output controls keep the plain version; invalid + // combinations keep their existing output-flag error. + const filtered = await cli.run({ + arguments: ["--version", "--json", "--filter-output", "version"], + cwd, + }); + expect(filtered.exitCode, filtered.stderr).toBe(0); + expect(filtered.stdout).toBe(`${version}\n`); + const windowed = await cli.run({ + arguments: ["--version", "--json", "--token-limit", "5"], + cwd, + }); + expect(windowed.exitCode).toBe(1); + expect(windowed.json).toMatchObject({ + error: { code: "UNSUPPORTED_OUTPUT_COMBINATION" }, + }); + + // Help keeps precedence over --version. + const help = await cli.run({ + arguments: ["--version", "--json", "--help"], + cwd, + }); + expect(help.exitCode, help.stderr).toBe(0); + expect(help.stdout).toContain("Usage: rea"); + }, +); diff --git a/tests/support/cli/cliCommandSessionWorker.mjs b/tests/support/cli/cliCommandSessionWorker.mjs index 683682542a..ba8c152786 100644 --- a/tests/support/cli/cliCommandSessionWorker.mjs +++ b/tests/support/cli/cliCommandSessionWorker.mjs @@ -50,11 +50,12 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { process.exit(1); } -const { cliConfigurationError, closeCliJavaScriptResources, createCli } = +const { closeCliJavaScriptResources, createCli } = await import("../../../dist/cli-runtime/cli.js"); const { createCliResultOutput } = await import("../../../dist/cli-runtime/cliResultOutput.js"); const { + cliConfigurationError, normalizeCliArguments, renderCliOutputArgumentError, renderEmptyFilteredCliOutput, diff --git a/tests/support/cli/compiledCliWorker.mjs b/tests/support/cli/compiledCliWorker.mjs index 50b08093d9..fdaac82cb6 100644 --- a/tests/support/cli/compiledCliWorker.mjs +++ b/tests/support/cli/compiledCliWorker.mjs @@ -1,11 +1,12 @@ import { Writable } from "node:stream"; import { createInterface } from "node:readline"; -const { cliConfigurationError, closeCliJavaScriptResources, createCli } = +const { closeCliJavaScriptResources, createCli } = await import("../../../dist/cli-runtime/cli.js"); const { createCliResultOutput } = await import("../../../dist/cli-runtime/cliResultOutput.js"); const { + cliConfigurationError, normalizeCliArguments, renderCliOutputArgumentError, renderEmptyFilteredCliOutput,