Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pre/render-cli-user-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

Add an optional user-facing message to CLI `UserError` values with safe cause-based fallbacks. `Command.run` and `Command.runWith` now render handler `UserError` failures through the installed output formatter; hosts that already print these errors should remove their duplicate output. Set `renderErrors: false` when the host should own error rendering.
3 changes: 2 additions & 1 deletion packages/effect/src/unstable/cli/Argument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,8 @@ export const map: {
* ? Effect.succeed(file)
* : Effect.fail(
* new CliError.UserError({
* cause: new Error("Only .txt files allowed")
* cause: new Error(`Unsupported file extension: ${file}`),
* userMessage: "Only .txt files allowed"
* })
* )
* )
Expand Down
30 changes: 28 additions & 2 deletions packages/effect/src/unstable/cli/CliError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,10 @@ export class UnknownSubcommand extends Schema.TaggedError<UnknownSubcommand>(
/**
* Error wrapper for user handler failures in the CLI error channel.
*
* `userMessage` can provide safe, user-facing text independently of the
* underlying cause. When omitted or empty, `message` uses a non-empty string
* cause or `Error.message`, then falls back to `"An error occurred"`.
*
* **Example** (Wrapping user errors)
*
* ```ts import.meta.vitest
Expand All @@ -486,7 +490,8 @@ export class UnknownSubcommand extends Schema.TaggedError<UnknownSubcommand>(
*
* // Wrapping user errors
* const userError = new CliError.UserError({
* cause: new Error("Database connection failed")
* cause: new Error("Database connection failed for postgres://localhost"),
* userMessage: "Could not connect to the database"
* })
*
* // In command handler
Expand Down Expand Up @@ -516,14 +521,35 @@ export class UnknownSubcommand extends Schema.TaggedError<UnknownSubcommand>(
export class UserError extends Schema.TaggedError<UserError>(
`${TypeId}/UserError`
)("UserError", {
cause: Schema.Defect()
cause: Schema.Defect(),
userMessage: Schema.optionalKey(Schema.String)
}) {
/**
* Marks this value as a user handler error for runtime guards.
*
* @since 4.0.0
*/
readonly [TypeId] = TypeId

/**
* Controls whether the runtime logger should report this error. The CLI
* runner sets this to `false` after rendering the error itself.
*
* @since 4.0.0
*/
override [Runtime.errorReported] = true

/**
* Returns the explicit user-facing message or a safe fallback from `cause`.
*
* @since 4.0.0
*/
override get message() {
if (this.userMessage) return this.userMessage
if (typeof this.cause === "string" && this.cause) return this.cause
if (this.cause instanceof Error && this.cause.message) return this.cause.message
return "An error occurred"
}
}

/**
Expand Down
38 changes: 34 additions & 4 deletions packages/effect/src/unstable/cli/Command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type * as Path from "../../Path.ts"
import * as Predicate from "../../Predicate.ts"
import * as References from "../../References.ts"
import * as Result from "../../Result.ts"
import * as Runtime from "../../Runtime.ts"
import * as Stdio from "../../Stdio.ts"
import * as Terminal from "../../Terminal.ts"
import type { Contravariant, Covariant, NoInfer, Simplify } from "../../Types.ts"
Expand Down Expand Up @@ -1665,18 +1666,26 @@ const getOutOfScopeGlobalFlagErrors = (

const showHelp = <Name extends string, Input, E, R, ContextInput>(
command: Command<Name, Input, ContextInput, E, R>,
error: CliError.ShowHelp
error: CliError.ShowHelp,
renderErrors: boolean
): Effect.Effect<void, CliError.CliError, Environment> =>
Effect.gen(function*() {
const { builtIns } = yield* CliConfig.CliConfig
const formatter = yield* CliOutput.Formatter
const helpDoc = yield* getHelpForCommandPath(command, error.commandPath, builtIns)
yield* Console.log(formatter.formatHelpDoc(helpDoc))
if (error.errors.length > 0) {
if (renderErrors && error.errors.length > 0) {
yield* Console.error(formatter.formatErrors(error.errors as any))
}
})

const showUserError = (error: CliError.UserError): Effect.Effect<void> =>
Effect.gen(function*() {
const formatter = yield* CliOutput.Formatter
yield* Console.error(formatter.formatError(error))
error[Runtime.errorReported] = false
})

/**
* Runs a command using the arguments supplied by the `Stdio` service.
*
Expand All @@ -1685,6 +1694,11 @@ const showHelp = <Name extends string, Input, E, R, ContextInput>(
* Use when command-line arguments should come from `Stdio` at the application
* entry point.
*
* Help documents are always rendered. By default, parse error details and
* `CliError.UserError` failures are also rendered with the installed
* `CliOutput.Formatter` before the error is rethrown. Set `renderErrors` to
* `false` when the host application owns error rendering.
*
* **Example** (Running commands with standard input)
*
* ```ts import.meta.vitest
Expand Down Expand Up @@ -1736,19 +1750,22 @@ const showHelp = <Name extends string, Input, E, R, ContextInput>(
export const run: {
(config: {
readonly version: string
readonly renderErrors?: boolean | undefined
}): <Name extends string, Input, E, R, ContextInput>(
command: Command<Name, Input, ContextInput, E, R>
) => Effect.Effect<void, E | CliError.CliError, R | Environment>
<Name extends string, Input, E, R, ContextInput>(
command: Command<Name, Input, ContextInput, E, R>,
config: {
readonly version: string
readonly renderErrors?: boolean | undefined
}
): Effect.Effect<void, E | CliError.CliError, R | Environment>
} = dual(2, <Name extends string, Input, E, R, ContextInput>(
command: Command<Name, Input, ContextInput, E, R>,
config: {
readonly version: string
readonly renderErrors?: boolean | undefined
}
) =>
Stdio.Stdio.use(({ args }) =>
Expand All @@ -1766,6 +1783,11 @@ export const run: {
* Use when you need to test CLI applications or programmatically execute
* commands with specific arguments.
*
* Help documents are always rendered. By default, parse error details and
* `CliError.UserError` failures are also rendered with the installed
* `CliOutput.Formatter` before the error is rethrown. Set `renderErrors` to
* `false` when the host application owns error rendering.
*
* **Example** (Running commands with explicit arguments)
*
* ```ts import.meta.vitest
Expand Down Expand Up @@ -1819,6 +1841,7 @@ export const runWith = <const Name extends string, Input, E, R, ContextInput>(
command: Command<Name, Input, ContextInput, E, R>,
config: {
readonly version: string
readonly renderErrors?: boolean | undefined
}
): (
input: ReadonlyArray<string>
Expand Down Expand Up @@ -1885,7 +1908,7 @@ export const runWith = <const Name extends string, Input, E, R, ContextInput>(
}))
if (shouldRun) {
yield* Console.log()
yield* runWith(command, config)(wizardArgs.slice(1))
yield* runWith(command, { ...config, renderErrors: false })(wizardArgs.slice(1))
}
}).pipe(
Effect.catchTag("QuitError", () => Console.log(Wizard.renderQuit()))
Expand Down Expand Up @@ -1933,7 +1956,14 @@ export const runWith = <const Name extends string, Input, E, R, ContextInput>(
CliError.isCliError(error) && error._tag === "ShowHelp"
? Result.succeed(error)
: Result.fail(error),
(error) => Effect.andThen(showHelp(command, error), Effect.fail(error))
(error) => Effect.andThen(showHelp(command, error, config.renderErrors !== false), Effect.fail(error))
),
Effect.catchFilter(
(error) =>
config.renderErrors !== false && CliError.isCliError(error) && error._tag === "UserError"
? Result.succeed(error)
: Result.fail(error),
(error) => Effect.andThen(showUserError(error), Effect.fail(error))
),
Effect.catchFilter(
(e) =>
Expand Down
108 changes: 106 additions & 2 deletions packages/effect/test/unstable/cli/Command.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { assert, describe, expect, it } from "@effect/vitest"
import { Context, Effect, Fiber, FileSystem, Layer, Option, Path, Stdio } from "effect"
import { Context, Effect, Fiber, FileSystem, Layer, Option, Path, Runtime, Stdio } from "effect"
import { TestConsole } from "effect/testing"
import { Argument, CliConfig, CliOutput, Command, Flag, GlobalFlag } from "effect/unstable/cli"
import { Argument, CliConfig, CliError, CliOutput, Command, Flag, GlobalFlag } from "effect/unstable/cli"
import { toImpl } from "effect/unstable/cli/internal/command"
import { ChildProcessSpawner } from "effect/unstable/process"
import * as Cli from "./fixtures/ComprehensiveCli.ts"
Expand Down Expand Up @@ -722,6 +722,110 @@ describe("Command", () => {
const result = yield* Effect.flip(Cli.run(["test-failing", "--input", "test"]))
assert.strictEqual(result, "Handler error")
}).pipe(Effect.provide(TestLayer)))

it.effect("should render and rethrow UserError handler failures without help", () =>
Effect.gen(function*() {
const failure = new CliError.UserError({
cause: new Error("internal details"),
userMessage: "Deployment failed"
})
const command = Command.make("deploy", {}, () => failure)

const error = yield* Effect.flip(Command.runWith(command, { version: "1.0.0" })([]))

assert.strictEqual(error, failure)
assert.isFalse(Runtime.getErrorReported(error))
const stderr = yield* TestConsole.errorLines
assert.lengthOf(stderr, 1)
assert.strictEqual(String(stderr[0]), "\nERROR\n Deployment failed")
assert.isEmpty(yield* TestConsole.logLines)
}).pipe(Effect.provide(TestLayer)))

it.effect("should render UserError handler failures with the installed formatter", () =>
Effect.gen(function*() {
const formatter: CliOutput.Formatter = {
...CliOutput.defaultFormatter({ colors: false }),
formatError: (error) => `CUSTOM ERROR: ${error.message}`
}
const failure = new CliError.UserError({ cause: "Deployment failed" })
const command = Command.make("deploy", {}, () => failure)

yield* Command.runWith(command, { version: "1.0.0" })([]).pipe(
Effect.flip,
Effect.provide(TestLayerWithoutFormatter),
Effect.provideService(CliOutput.Formatter, formatter)
)

assert.deepStrictEqual(yield* TestConsole.errorLines, ["CUSTOM ERROR: Deployment failed"])
}))

it.effect("should render UserError once when running wizard-generated arguments", () =>
Effect.gen(function*() {
const failure = new CliError.UserError({ cause: "Deployment failed" })
const command = Command.make("deploy", {}, () => failure)

const fiber = yield* Command.runWith(command, { version: "1.0.0" })(["--wizard"]).pipe(
Effect.flip,
Effect.forkChild
)
yield* MockTerminal.inputKey("enter")
const error = yield* Fiber.join(fiber)

assert.strictEqual(error, failure)
assert.deepStrictEqual(yield* TestConsole.errorLines, ["\nERROR\n Deployment failed"])
}).pipe(Effect.provide(TestLayer)))

it.effect("should render UserError argument failures with command help", () =>
Effect.gen(function*() {
const command = Command.make("deploy", {
target: Argument.string("target").pipe(
Argument.mapEffect(() =>
Effect.fail(
new CliError.UserError({
cause: "Invalid deployment target"
})
)
)
)
})

const error = yield* Effect.flip(Command.runWith(command, { version: "1.0.0" })(["invalid"]))

assert.instanceOf(error, CliError.ShowHelp)
assert.include((yield* TestConsole.errorLines).join("\n"), "Invalid deployment target")
assert.include((yield* TestConsole.logLines).join("\n"), "USAGE")
}).pipe(Effect.provide(TestLayer)))

it.effect("should suppress automatic error rendering", () =>
Effect.gen(function*() {
const userError = new CliError.UserError({ cause: "Deployment failed" })
const command = Command.make("deploy", {}, () => userError)
const config = { version: "1.0.0", renderErrors: false } as const

const handlerFailure = yield* Effect.flip(Command.runWith(command, config)([]))
const parseFailure = yield* Effect.flip(Command.runWith(command, config)(["--unknown"]))

assert.strictEqual(handlerFailure, userError)
assert.isTrue(Runtime.getErrorReported(handlerFailure))
assert.instanceOf(parseFailure, CliError.ShowHelp)
assert.isEmpty(yield* TestConsole.errorLines)
assert.include((yield* TestConsole.logLines).join("\n"), "USAGE")
}).pipe(Effect.provide(TestLayer)))

it.effect("should still render help when automatic error rendering is disabled", () =>
Effect.gen(function*() {
const child = Command.make("child")
const command = Command.make("app").pipe(Command.withSubcommands([child]))

const error = yield* Effect.flip(
Command.runWith(command, { version: "1.0.0", renderErrors: false })([])
)

assert.instanceOf(error, CliError.ShowHelp)
assert.isEmpty(error.errors)
assert.include((yield* TestConsole.logLines).join("\n"), "USAGE")
assert.isEmpty(yield* TestConsole.errorLines)
}).pipe(Effect.provide(TestLayer)))
})

describe("withSubcommands", () => {
Expand Down
70 changes: 69 additions & 1 deletion packages/effect/test/unstable/cli/Errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @effect-diagnostics floatingEffect:skip-file
import { assert, describe, it } from "@effect/vitest"
import { Effect, FileSystem, Layer, Path, Stdio } from "effect"
import { Effect, FileSystem, Layer, Path, Runtime, Stdio } from "effect"
import { Argument, CliError, CliOutput, Command, Flag } from "effect/unstable/cli"
import { toImpl } from "effect/unstable/cli/internal/command"
import * as Lexer from "effect/unstable/cli/internal/lexer"
Expand Down Expand Up @@ -295,6 +295,74 @@ describe("Command errors", () => {
})
})

describe("UserError", () => {
it("prefers the user-facing message over the cause", () => {
const error = new CliError.UserError({
cause: new Error("internal details"),
userMessage: "Could not deploy the application"
})

assert.strictEqual(error.message, "Could not deploy the application")
})

it("uses an Error cause message as the fallback", () => {
const error = new CliError.UserError({
cause: new Error("Connection refused")
})

assert.strictEqual(error.message, "Connection refused")
})

it("uses a string cause as the fallback", () => {
const error = new CliError.UserError({ cause: "Connection refused" })

assert.strictEqual(error.message, "Connection refused")
})

it("uses a generic fallback for causes without a message", () => {
const error = new CliError.UserError({ cause: { status: 503 } })

assert.strictEqual(error.message, "An error occurred")
})

it("falls back past empty user-facing and cause messages", () => {
const emptyUserMessage = new CliError.UserError({
cause: new Error("Connection refused"),
userMessage: ""
})
const emptyCause = new CliError.UserError({ cause: "" })

assert.strictEqual(emptyUserMessage.message, "Connection refused")
assert.strictEqual(emptyCause.message, "An error occurred")
})

it("allows runtime reporting before the CLI runner renders it", () => {
const error = new CliError.UserError({ cause: "failed" })

assert.isTrue(Runtime.getErrorReported(error))
})

it("escapes control characters in the user-facing message", () => {
const formatter = CliOutput.defaultFormatter({ colors: false })
const error = new CliError.UserError({
cause: "internal details",
userMessage: "Deployment failed\x1b]52;c;bWFsaWNpb3Vz\x07"
})

assert.strictEqual(
formatter.formatError(error),
"\nERROR\n Deployment failed\\x1b]52;c;bWFsaWNpb3Vz\\x07"
)
})

it("formats the resolved fallback message with other CLI errors", () => {
const formatter = CliOutput.defaultFormatter({ colors: false })
const error = new CliError.UserError({ cause: new Error("Connection refused") })

assert.strictEqual(formatter.formatErrors([error]), "\nERROR\n Connection refused")
})
})

describe("InvalidValue", () => {
it("labels a bare expected description", () => {
const error = new CliError.InvalidValue({
Expand Down
Loading