From ea3d2f13ea67d6ad5702ebc8d2436fba8c628faa Mon Sep 17 00:00:00 2001 From: Rik Smale Date: Thu, 13 Aug 2026 12:30:07 +0200 Subject: [PATCH] Run boolean flag fallbacks when the flag is absent `Param.withFallbackConfig` and `Param.withFallbackPrompt` both trigger on the `MissingOption` / `MissingArgument` errors, which a boolean flag never produces: an absent boolean flag parses as `false`. Applying either combinator to a `Flag.boolean` was therefore a silent no-op, including in the example documented on `Flag.withFallbackConfig`. Both combinators now detect the boolean flag they read and consult the fallback when that flag is absent from the parsed flags, before parsing resolves it to `false`. The parser records aliases and `--no-` negations under the canonical flag name, so an explicit `--flag` or `--no-flag` still wins. Params wrapped by `optional` or `withDefault`, and those with `orElse` alternatives, keep supplying their own value. A missing config still falls back to `false`; a cancelled prompt fails with `MissingOption`, as it does for other flags. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/cli-boolean-flag-fallbacks.md | 15 ++ packages/effect/src/unstable/cli/Param.ts | 79 +++++++- .../effect/test/unstable/cli/Param.test.ts | 176 +++++++++++++++++- 3 files changed, 257 insertions(+), 13 deletions(-) create mode 100644 .changeset/cli-boolean-flag-fallbacks.md diff --git a/.changeset/cli-boolean-flag-fallbacks.md b/.changeset/cli-boolean-flag-fallbacks.md new file mode 100644 index 00000000000..51232570d0a --- /dev/null +++ b/.changeset/cli-boolean-flag-fallbacks.md @@ -0,0 +1,15 @@ +--- +"effect": patch +--- + +Run `withFallbackConfig` and `withFallbackPrompt` for boolean flags that are absent from the command line. + +Both fallbacks were driven by the `MissingOption` error, which a boolean flag never produces because it parses as `false` when absent. Applying either combinator to a `Flag.boolean` therefore did nothing, including in the documented example: + +```ts +const verbose = Flag.boolean("verbose").pipe( + Flag.withFallbackConfig(Config.boolean("VERBOSE")) +) +``` + +The fallback now runs whenever the flag is absent, so `VERBOSE=true` is honored. An explicit `--verbose` or `--no-verbose` still wins, and `optional`/`withDefault` still supply the value before any fallback is consulted. When the config is missing the flag falls back to `false` as before; a cancelled prompt fails with `MissingOption`, matching the existing behavior for other flags. diff --git a/packages/effect/src/unstable/cli/Param.ts b/packages/effect/src/unstable/cli/Param.ts index d9ed6ded98b..c1c2fdf8227 100644 --- a/packages/effect/src/unstable/cli/Param.ts +++ b/packages/effect/src/unstable/cli/Param.ts @@ -1356,6 +1356,37 @@ export const withDefault: { ) }) +/** + * Returns the name of the boolean flag a param reads, if it reads exactly one + * and always parses it. + * + * An absent boolean flag parses as `false` instead of failing with + * `MissingOption`, so fallbacks that react to that error never run for one. + * Fallback combinators use this to detect the absent case themselves. Params + * wrapped by `optional` or `withDefault` already supply their own value, and + * alternatives introduced by `orElse` may provide the value instead, so neither + * takes part in a fallback. + */ +const booleanFlagName = (self: Param): string | undefined => + matchParam(self, { + Single: (single) => single.kind === "flag" && Primitive.isBoolean(single.primitiveType) ? single.name : undefined, + Map: (mapped) => booleanFlagName(mapped.param), + Transform: (transformed) => transformed.alternatives.length === 0 ? booleanFlagName(transformed.param) : undefined, + Optional: () => undefined, + Variadic: () => undefined + }) + +/** + * Checks whether a flag was provided on the command line. + * + * The parser records aliases and `--no-` negations under the canonical flag + * name, so an explicitly negated boolean flag counts as provided. + */ +const isFlagProvided = (args: ParsedArgs, name: string): boolean => { + const values = args.flags[name] + return values !== undefined && values.length > 0 +} + /** * Adds a fallback config that is loaded when a required parameter is missing. * @@ -1367,12 +1398,14 @@ export const withDefault: { * **Details** * * Provided CLI values win. Config is loaded only after a missing option or - * missing argument error. + * missing argument error. A boolean flag is never missing because it parses as + * `false` when absent, so config is loaded whenever the flag is absent from the + * command line, and `--flag` or `--no-flag` still wins. * * **Gotchas** * - * Missing config preserves the original missing-parameter error. Config parse - * failure becomes `CliError.InvalidValue`. + * Missing config preserves the original missing-parameter error, or `false` for + * a boolean flag. Config parse failure becomes `CliError.InvalidValue`. * * @see {@link withDefault} for a pure default value * @see {@link withFallbackPrompt} for prompting interactively when input is missing @@ -1397,20 +1430,36 @@ export const withFallbackConfig: { expected: configError.message, kind: error._tag === "MissingOption" ? "flag" : "argument" }) - const runConfig = (error: CliError.MissingOption | CliError.MissingArgument, args: ParsedArgs) => + const runConfig = ( + error: CliError.MissingOption | CliError.MissingArgument, + args: ParsedArgs, + onMissing: LazyArg< + Effect.Effect, value: A | B], CliError.CliError, Environment> + > + ) => Config.option(config).pipe( Effect.mapError((configError) => toInvalidValue(error, configError)), Effect.flatMap(Option.match({ - onNone: () => Effect.fail(error), + onNone: onMissing, onSome: (value) => Effect.succeed([args.arguments, value as A | B] as const) })) ) + const flagName = booleanFlagName(self) return transform( self, - (parse) => (args) => - parse(args).pipe( - Effect.catchTag(["MissingOption", "MissingArgument"], (error) => runConfig(error, args)) + (parse) => (args) => { + // A boolean flag parses as `false` when absent instead of failing, so the + // config has to be loaded before parsing rather than after it fails. + if (flagName !== undefined && !isFlagProvided(args, flagName)) { + return runConfig(new CliError.MissingOption({ option: flagName }), args, () => parse(args)) + } + return parse(args).pipe( + Effect.catchTag( + ["MissingOption", "MissingArgument"], + (error) => runConfig(error, args, () => Effect.fail(error)) + ) ) + } ) }) @@ -1426,6 +1475,9 @@ export const withFallbackConfig: { * * `FallbackPrompt` accepts either a `Prompt` or an effect that builds one. * Effectful prompt creation is lazy and runs only when the fallback is needed. + * A boolean flag is never missing because it parses as `false` when absent, so + * the prompt runs whenever the flag is absent from the command line, and + * `--flag` or `--no-flag` still wins. * * **Gotchas** * @@ -1451,12 +1503,19 @@ export const withFallbackPrompt: { Effect.map((value) => [args.arguments, value as A | B] as const), Effect.catchTag("QuitError", () => Effect.fail(error)) ) + const flagName = booleanFlagName(self) return transform( self, - (parse) => (args) => - parse(args).pipe( + (parse) => (args) => { + // A boolean flag parses as `false` when absent instead of failing, so the + // prompt has to run before parsing rather than after it fails. + if (flagName !== undefined && !isFlagProvided(args, flagName)) { + return runPrompt(new CliError.MissingOption({ option: flagName }), args) + } + return parse(args).pipe( Effect.catchTag(["MissingOption", "MissingArgument"], (error) => runPrompt(error, args)) ) + } ) }) diff --git a/packages/effect/test/unstable/cli/Param.test.ts b/packages/effect/test/unstable/cli/Param.test.ts index ef3642fb25e..b0d545a6741 100644 --- a/packages/effect/test/unstable/cli/Param.test.ts +++ b/packages/effect/test/unstable/cli/Param.test.ts @@ -271,19 +271,69 @@ describe("Param", () => { assert.instanceOf(error, CliError.InvalidValue) }).pipe(Effect.provide(TestLayer))) - it.effect("does not prompt for missing boolean flags", () => + it.effect("prompts for boolean flags that are absent", () => Effect.gen(function*() { - const prompt = Prompt.text({ message: "Verbose" }) + const prompt = Prompt.confirm({ message: "Verbose" }) const flag = Flag.boolean("verbose").pipe(Flag.withFallbackPrompt(prompt)) - const [, value] = yield* flag.parse({ + yield* MockTerminal.inputText("y") + + const [remaining, value] = yield* flag.parse({ flags: {}, + arguments: ["tail"] + }) + + assert.strictEqual(value, true) + assert.deepStrictEqual(remaining, ["tail"]) + }).pipe(Effect.provide(TestLayer))) + + it.effect("does not prompt for negated boolean flags", () => + Effect.gen(function*() { + const prompt = Prompt.confirm({ message: "Verbose" }) + const flag = Flag.boolean("verbose").pipe(Flag.withFallbackPrompt(prompt)) + + // `--no-verbose` is recorded under the canonical flag name + const [, value] = yield* flag.parse({ + flags: { verbose: ["false"] }, arguments: [] }) assert.strictEqual(value, false) }).pipe(Effect.provide(TestLayer))) + it.effect("prefers defaults over fallback prompts for boolean flags", () => + Effect.gen(function*() { + const prompt = Prompt.confirm({ message: "Verbose" }) + const flag = Flag.boolean("verbose").pipe( + Flag.withDefault(true), + Flag.withFallbackPrompt(prompt) + ) + + const [, value] = yield* flag.parse({ + flags: {}, + arguments: [] + }) + + assert.strictEqual(value, true) + }).pipe(Effect.provide(TestLayer))) + + it.effect("returns MissingOption when a boolean flag prompt is cancelled", () => + Effect.gen(function*() { + const prompt = Prompt.confirm({ message: "Verbose" }) + const flag = Flag.boolean("verbose").pipe(Flag.withFallbackPrompt(prompt)) + + yield* MockTerminal.inputKey("c", { ctrl: true }) + + const error = yield* Effect.flip( + flag.parse({ + flags: {}, + arguments: [] + }) + ) + + assert.instanceOf(error, CliError.MissingOption) + }).pipe(Effect.provide(TestLayer))) + it.effect("returns MissingOption when prompt is cancelled", () => Effect.gen(function*() { const prompt = Prompt.text({ message: "Name" }) @@ -439,5 +489,125 @@ describe("Param", () => { Effect.provide(TestLayer) ) }) + + it.effect("uses ConfigProvider when a boolean flag is absent", () => { + const provider = ConfigProvider.fromEnv({ + env: { + VERBOSE: "true" + } + }) + + return Effect.gen(function*() { + const flag = Flag.boolean("verbose").pipe( + Flag.withFallbackConfig(Config.boolean("VERBOSE")) + ) + + const [, value] = yield* flag.parse({ + flags: {}, + arguments: [] + }) + + assert.strictEqual(value, true) + }).pipe( + Effect.provideService(ConfigProvider.ConfigProvider, provider), + Effect.provide(TestLayer) + ) + }) + + it.effect("uses negated boolean flags before reading config fallbacks", () => { + const provider = ConfigProvider.fromEnv({ + env: { + VERBOSE: "true" + } + }) + + return Effect.gen(function*() { + const flag = Flag.boolean("verbose").pipe( + Flag.withFallbackConfig(Config.boolean("VERBOSE")) + ) + + // `--no-verbose` is recorded under the canonical flag name + const [, value] = yield* flag.parse({ + flags: { verbose: ["false"] }, + arguments: [] + }) + + assert.strictEqual(value, false) + }).pipe( + Effect.provideService(ConfigProvider.ConfigProvider, provider), + Effect.provide(TestLayer) + ) + }) + + it.effect("returns false when a boolean flag is absent and config is missing", () => { + const provider = ConfigProvider.fromEnv({ env: {} }) + + return Effect.gen(function*() { + const flag = Flag.boolean("verbose").pipe( + Flag.withFallbackConfig(Config.boolean("VERBOSE")) + ) + + const [, value] = yield* flag.parse({ + flags: {}, + arguments: [] + }) + + assert.strictEqual(value, false) + }).pipe( + Effect.provideService(ConfigProvider.ConfigProvider, provider), + Effect.provide(TestLayer) + ) + }) + + it.effect("prefers defaults over config fallbacks for boolean flags", () => { + const provider = ConfigProvider.fromEnv({ + env: { + VERBOSE: "false" + } + }) + + return Effect.gen(function*() { + const flag = Flag.boolean("verbose").pipe( + Flag.withDefault(true), + Flag.withFallbackConfig(Config.boolean("VERBOSE")) + ) + + const [, value] = yield* flag.parse({ + flags: {}, + arguments: [] + }) + + assert.strictEqual(value, true) + }).pipe( + Effect.provideService(ConfigProvider.ConfigProvider, provider), + Effect.provide(TestLayer) + ) + }) + + it.effect("returns InvalidValue when boolean config fails to parse", () => { + const provider = ConfigProvider.fromEnv({ + env: { + VERBOSE: "nope" + } + }) + + return Effect.gen(function*() { + const flag = Flag.boolean("verbose").pipe( + Flag.withFallbackConfig(Config.boolean("VERBOSE")) + ) + + const error = yield* Effect.flip( + flag.parse({ + flags: {}, + arguments: [] + }) + ) + + assert.instanceOf(error, CliError.InvalidValue) + }).pipe( + Effect.provideService(ConfigProvider.ConfigProvider, provider), + Effect.provide(TestLayer) + ) + }) }) })