diff --git a/scripts/rea.mjs b/scripts/rea.mjs index 3b1ef6c4a5..d3380c9c2a 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -58,7 +58,8 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { process.stdout.write(result.output); process.exitCode = result.exitCode; } else { - const { createCli } = await import("../dist/cli-runtime/cli.js"); + const { cliConfigurationError, createCli } = + await import("../dist/cli-runtime/cli.js"); const { createStreamedCliJsonOutput } = await import("../dist/cli-runtime/streamedJsonOutput.js"); const { @@ -67,7 +68,11 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { validateCliOutputArguments, } = await import("../dist/cli-runtime/cliOutput.js"); const outputArguments = validateCliOutputArguments(args); - if (!outputArguments.ok) { + const configurationError = cliConfigurationError(process.env); + if (configurationError !== undefined) { + process.stderr.write(`${configurationError}\n`); + process.exitCode = 1; + } else if (!outputArguments.ok) { process.stdout.write(renderCliOutputArgumentError(outputArguments)); process.exitCode = 1; } else { diff --git a/src/cli.ts b/src/cli.ts index 1cbe3b896d..5438fd472d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsComman import { Cli } from "incur"; import { createLogger, parseLogLevel } from "./logger.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"; @@ -41,6 +42,16 @@ import { type CliResultOutput, } from "./cli/streamedJsonOutput.js"; +/** Describe CLI settings that are invalid before any command runs, as MCP does. */ +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 @@ -54,11 +65,14 @@ export const createCli = ( ): CliInstance => { const environment = snapshotEnvironment(selectedEnvironment); const analysis = createDirectAnalysis(environment); + // The executable reports an invalid level through cliConfigurationError. + const level = + environment.REA_LOG_LEVEL === undefined + ? undefined + : parseLogLevel(environment.REA_LOG_LEVEL); const logger = createLogger( "cli", - environment.REA_LOG_LEVEL === undefined - ? "silent" - : parseLogLevel(environment.REA_LOG_LEVEL), + level?.ok === true ? level.value : "silent", ); const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, { version: PRODUCT_IDENTITY.packageVersion, diff --git a/src/logger.ts b/src/logger.ts index e43c7f97cc..05fc3822fe 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,6 +1,9 @@ import pino, { type Logger } from "pino"; import { z } from "zod"; +import { ConfigurationError } from "./domain/configurationErrors.js"; +import { err, ok, type Result } from "./domain/result.js"; + /** Supported structured log levels at the process boundary. */ export type LogLevel = z.infer; @@ -9,8 +12,21 @@ const logLevelSchema = z .default("info"); /** Parse the optional REA_LOG_LEVEL environment variable. */ -export const parseLogLevel = (value: unknown): LogLevel => - logLevelSchema.parse(value); +export const parseLogLevel = ( + value: unknown, +): Result => { + const parsed = logLevelSchema.safeParse(value); + if (parsed.success) return ok(parsed.data); + return err( + new ConfigurationError("Invalid REA environment configuration", { + cause: parsed.error, + settings: parsed.error.issues.map(({ message }) => ({ + setting: "REA_LOG_LEVEL", + constraint: message, + })), + }), + ); +}; /** Create a JSON logger on stderr so protocol and CLI result stdout stay valid. */ export const createLogger = (mode: "mcp" | "cli", level: LogLevel): Logger => diff --git a/tests/boundary/cli/cliLogLevelConfiguration.test.ts b/tests/boundary/cli/cliLogLevelConfiguration.test.ts new file mode 100644 index 0000000000..cc09a3b944 --- /dev/null +++ b/tests/boundary/cli/cliLogLevelConfiguration.test.ts @@ -0,0 +1,21 @@ +import { expect } from "vitest"; + +import { cliTest } from "../../support/cli/cliFixture.js"; + +for (const level of ["loud", "INFO"]) + cliTest( + `reports REA_LOG_LEVEL=${level} as invalid configuration`, + async ({ cli }) => { + for (const arguments_ of [["doctor", "--json"], ["--help"]]) { + const result = await cli.run({ + arguments: arguments_, + environment: { REA_LOG_LEVEL: level }, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toBe( + 'REA configuration is invalid: REA_LOG_LEVEL: Invalid option: expected one of "trace"|"debug"|"info"|"warn"|"error"|"fatal"|"silent".\n', + ); + } + }, + ); diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts index 583bd601dd..efd6ca4144 100644 --- a/tests/boundary/cli/dispatcher.test.ts +++ b/tests/boundary/cli/dispatcher.test.ts @@ -25,7 +25,7 @@ describe("executable dispatcher", () => { ), workspace.write( "dist/cli-runtime/cli.js", - 'export const createCli = () => ({ serve: (args) => process.stdout.write(JSON.stringify({ adapter: "cli", args })) });\n', + 'export const cliConfigurationError = () => undefined; export const createCli = () => ({ serve: (args) => process.stdout.write(JSON.stringify({ adapter: "cli", args })) });\n', ), workspace.write( "dist/cli-runtime/cliOutput.js",