diff --git a/.changeset/violet-berries-appear.md b/.changeset/violet-berries-appear.md new file mode 100644 index 0000000..6fb6ee4 --- /dev/null +++ b/.changeset/violet-berries-appear.md @@ -0,0 +1,5 @@ +--- +"@effect/cli": patch +--- + +add Args/Options.withFallbackConfig diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..6461dee --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +*.ts diff --git a/examples/minigit.ts b/examples/minigit.ts index 046b1dd..e5397f9 100644 --- a/examples/minigit.ts +++ b/examples/minigit.ts @@ -1,6 +1,6 @@ import { Args, Command, Options } from "@effect/cli" import { NodeContext, Runtime } from "@effect/platform-node" -import { Console, Effect, Option, ReadonlyArray } from "effect" +import { Config, ConfigProvider, Console, Effect, Option, ReadonlyArray } from "effect" // minigit [--version] [-h | --help] [-c =] const configs = Options.keyValueMap("c").pipe(Options.optional) @@ -17,7 +17,10 @@ const minigit = Command.make("minigit", { configs }, ({ configs }) => // minigit add [-v | --verbose] [--] [...] const pathspec = Args.text({ name: "pathspec" }).pipe(Args.repeated) -const verbose = Options.boolean("verbose").pipe(Options.withAlias("v")) +const verbose = Options.boolean("verbose").pipe( + Options.withAlias("v"), + Options.withFallbackConfig(Config.boolean("VERBOSE")) +) const minigitAdd = Command.make("add", { pathspec, verbose }, ({ pathspec, verbose }) => { const paths = ReadonlyArray.match(pathspec, { onEmpty: () => "", @@ -29,7 +32,10 @@ const minigitAdd = Command.make("add", { pathspec, verbose }, ({ pathspec, verbo // minigit clone [--depth ] [--] [] const repository = Args.text({ name: "repository" }) const directory = Args.directory().pipe(Args.optional) -const depth = Options.integer("depth").pipe(Options.optional) +const depth = Options.integer("depth").pipe( + Options.withFallbackConfig(Config.integer("DEPTH")), + Options.optional +) const minigitClone = Command.make( "clone", { repository, directory, depth }, @@ -58,6 +64,7 @@ const cli = Command.run(command, { }) Effect.suspend(() => cli(process.argv.slice(2))).pipe( + Effect.withConfigProvider(ConfigProvider.nested(ConfigProvider.fromEnv(), "GIT")), Effect.provide(NodeContext.layer), Runtime.runMain ) diff --git a/src/Args.ts b/src/Args.ts index b88cb97..b7f2508 100644 --- a/src/Args.ts +++ b/src/Args.ts @@ -3,6 +3,7 @@ */ import type { FileSystem } from "@effect/platform/FileSystem" import type { QuitException, Terminal } from "@effect/platform/Terminal" +import type { Config } from "effect/Config" import type { Effect } from "effect/Effect" import type { Either } from "effect/Either" import type { Option } from "effect/Option" @@ -353,6 +354,15 @@ export const withDefault: { (self: Args, fallback: B): Args } = InternalArgs.withDefault +/** + * @since 1.0.0 + * @category combinators + */ +export const withFallbackConfig: { + (config: Config): (self: Args) => Args + (self: Args, config: Config): Args +} = InternalArgs.withFallbackConfig + /** * @since 1.0.0 * @category combinators diff --git a/src/Options.ts b/src/Options.ts index 54d01d7..d1e999a 100644 --- a/src/Options.ts +++ b/src/Options.ts @@ -3,6 +3,7 @@ */ import type { FileSystem } from "@effect/platform/FileSystem" import type { QuitException, Terminal } from "@effect/platform/Terminal" +import type { Config } from "effect/Config" import type { Effect } from "effect/Effect" import type { Either } from "effect/Either" import type { HashMap } from "effect/HashMap" @@ -451,6 +452,15 @@ export const withDefault: { (self: Options, fallback: B): Options } = InternalOptions.withDefault +/** + * @since 1.0.0 + * @category combinators + */ +export const withFallbackConfig: { + (config: Config): (self: Options) => Options + (self: Options, config: Config): Options +} = InternalOptions.withFallbackConfig + /** * @since 1.0.0 * @category combinators diff --git a/src/internal/args.ts b/src/internal/args.ts index 3c4d559..9181829 100644 --- a/src/internal/args.ts +++ b/src/internal/args.ts @@ -1,5 +1,6 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Terminal from "@effect/platform/Terminal" +import type * as Config from "effect/Config" import * as Console from "effect/Console" import * as Effect from "effect/Effect" import * as Either from "effect/Either" @@ -51,6 +52,7 @@ export type Instruction = | Both | Variadic | WithDefault + | WithFallbackConfig /** @internal */ export interface Empty extends Op<"Empty", {}> {} @@ -98,6 +100,14 @@ export interface WithDefault extends }> {} +/** @internal */ +export interface WithFallbackConfig extends + Op<"WithFallbackConfig", { + readonly args: Args.Args + readonly config: Config.Config + }> +{} + // ============================================================================= // Refinements // ============================================================================= @@ -106,6 +116,9 @@ export interface WithDefault extends export const isArgs = (u: unknown): u is Args.Args => typeof u === "object" && u != null && ArgsTypeId in u +/** @internal */ +export const isInstruction = <_>(self: Args.Args<_>): self is Instruction => self as any + /** @internal */ export const isEmpty = (self: Instruction): self is Empty => self._tag === "Empty" @@ -121,6 +134,13 @@ export const isMap = (self: Instruction): self is Map => self._tag === "Map" /** @internal */ export const isVariadic = (self: Instruction): self is Variadic => self._tag === "Variadic" +/** @internal */ +export const isWithDefault = (self: Instruction): self is WithDefault => self._tag === "WithDefault" + +/** @internal */ +export const isWithFallbackConfig = (self: Instruction): self is WithFallbackConfig => + self._tag === "WithFallbackConfig" + // ============================================================================= // Constructors // ============================================================================= @@ -333,6 +353,23 @@ export const withDefault = dual< (self: Args.Args, fallback: B) => Args.Args >(2, (self, fallback) => makeWithDefault(self, fallback)) +/** @internal */ +export const withFallbackConfig: { + (config: Config.Config): (self: Args.Args) => Args.Args + (self: Args.Args, config: Config.Config): Args.Args +} = dual< + (config: Config.Config) => (self: Args.Args) => Args.Args, + (self: Args.Args, config: Config.Config) => Args.Args +>(2, (self, config) => { + if (isInstruction(self) && isWithDefault(self)) { + return makeWithDefault( + withFallbackConfig(self.args, config), + self.fallback as any + ) + } + return makeWithFallbackConfig(self, config) +}) + /** @internal */ export const withDescription = dual< (description: string) => (self: Args.Args) => Args.Args, @@ -432,6 +469,20 @@ const getHelpInternal = (self: Instruction): HelpDoc.HelpDoc => { } ) } + case "WithFallbackConfig": { + return InternalHelpDoc.mapDescriptionList( + getHelpInternal(self.args as Instruction), + (span, block) => [ + span, + InternalHelpDoc.sequence( + block, + InternalHelpDoc.p( + "This argument can be set from environment variables." + ) + ) + ] + ) + } } } @@ -445,7 +496,8 @@ const getIdentifierInternal = (self: Instruction): Option.Option => { } case "Map": case "Variadic": - case "WithDefault": { + case "WithDefault": + case "WithFallbackConfig": { return getIdentifierInternal(self.args as Instruction) } case "Both": { @@ -464,7 +516,8 @@ const getIdentifierInternal = (self: Instruction): Option.Option => { const getMinSizeInternal = (self: Instruction): number => { switch (self._tag) { case "Empty": - case "WithDefault": { + case "WithDefault": + case "WithFallbackConfig": { return 0 } case "Single": { @@ -494,7 +547,8 @@ const getMaxSizeInternal = (self: Instruction): number => { return 1 } case "Map": - case "WithDefault": { + case "WithDefault": + case "WithFallbackConfig": { return getMaxSizeInternal(self.args as Instruction) } case "Both": { @@ -532,7 +586,8 @@ const getUsageInternal = (self: Instruction): Usage.Usage => { case "Variadic": { return InternalUsage.repeated(getUsageInternal(self.args as Instruction)) } - case "WithDefault": { + case "WithDefault": + case "WithFallbackConfig": { return InternalUsage.optional(getUsageInternal(self.args as Instruction)) } } @@ -582,6 +637,17 @@ const makeWithDefault = ( return op } +const makeWithFallbackConfig = ( + args: Args.Args, + config: Config.Config +): Args.Args => { + const op = Object.create(proto) + op._tag = "WithFallbackConfig" + op.args = args + op.config = config + return op +} + const makeVariadic = ( args: Args.Args, min: Option.Option, @@ -699,6 +765,15 @@ const validateInternal = ( ])) ) } + case "WithFallbackConfig": { + return validateInternal(self.args as Instruction, args, config).pipe( + Effect.catchTag("MissingValue", (e) => + Effect.map( + Effect.mapError(Effect.config(self.config), () => e), + (value) => [args, value] as [ReadonlyArray, any] + )) + ) + } } } @@ -733,6 +808,12 @@ const withDescriptionInternal = (self: Instruction, description: string): Args.A self.fallback ) } + case "WithFallbackConfig": { + return makeWithFallbackConfig( + withDescriptionInternal(self.args as Instruction, description), + self.config + ) + } } } @@ -816,6 +897,27 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. ) ) } + case "WithFallbackConfig": { + const defaultHelp = InternalHelpDoc.p(`Try load this option from the environment?`) + const message = pipe( + getHelpInternal(self.args as Instruction), + InternalHelpDoc.sequence(defaultHelp) + ) + return InternalSelectPrompt.select({ + message: InternalHelpDoc.toAnsiText(message).trimEnd(), + choices: [ + { title: `Use environment variables`, value: true }, + { title: "Custom", value: false } + ] + }).pipe( + Effect.zipLeft(Console.log()), + Effect.flatMap((useFallback) => + useFallback + ? Effect.succeed(ReadonlyArray.empty()) + : wizardInternal(self.args as Instruction, config) + ) + ) + } } } @@ -834,7 +936,8 @@ const getShortDescription = (self: Instruction): string => { } case "Map": case "Variadic": - case "WithDefault": { + case "WithDefault": + case "WithFallbackConfig": { return getShortDescription(self.args as Instruction) } } @@ -869,7 +972,8 @@ export const getFishCompletions = (self: Instruction): ReadonlyArray => } case "Map": case "Variadic": - case "WithDefault": { + case "WithDefault": + case "WithFallbackConfig": { return getFishCompletions(self.args as Instruction) } } @@ -913,7 +1017,8 @@ export const getZshCompletions = ( ? getZshCompletions(self.args as Instruction, { ...state, multiple: true }) : getZshCompletions(self.args as Instruction, state) } - case "WithDefault": { + case "WithDefault": + case "WithFallbackConfig": { return getZshCompletions(self.args as Instruction, { ...state, optional: true }) } } diff --git a/src/internal/options.ts b/src/internal/options.ts index b5fe78f..53b4ca8 100644 --- a/src/internal/options.ts +++ b/src/internal/options.ts @@ -1,5 +1,6 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Terminal from "@effect/platform/Terminal" +import type * as Config from "effect/Config" import * as Console from "effect/Console" import * as Effect from "effect/Effect" import * as Either from "effect/Either" @@ -56,6 +57,7 @@ export type Instruction = | Map | Both | OrElse + | WithFallbackConfig | Variadic | WithDefault @@ -109,6 +111,14 @@ export interface OrElse extends }> {} +/** @internal */ +export interface WithFallbackConfig extends + Op<"WithFallbackConfig", { + readonly options: Options.Options + readonly config: Config.Config + }> +{} + /** @internal */ export interface Variadic extends Op<"Variadic", { @@ -134,6 +144,9 @@ export interface WithDefault extends export const isOptions = (u: unknown): u is Options.Options => typeof u === "object" && u != null && OptionsTypeId in u +/** @internal */ +export const isInstruction = <_>(self: Options.Options<_>): self is Instruction => self as any + /** @internal */ export const isEmpty = (self: Instruction): self is Empty => self._tag === "Empty" @@ -155,6 +168,10 @@ export const isOrElse = (self: Instruction): self is OrElse => self._tag === "Or /** @internal */ export const isWithDefault = (self: Instruction): self is WithDefault => self._tag === "WithDefault" +/** @internal */ +export const isWithFallbackConfig = (self: Instruction): self is WithFallbackConfig => + self._tag === "WithFallbackConfig" + // ============================================================================= // Constructors // ============================================================================= @@ -214,7 +231,10 @@ export const boolean = ( tail, InternalPrimitive.boolean(Option.some(!ifPresent)) ) - return withDefault(orElse(option, negationOption), !ifPresent) + return withDefault( + orElse(option, negationOption), + !ifPresent + ) } return withDefault(option, !ifPresent) } @@ -519,6 +539,23 @@ export const withDefault = dual< (self: Options.Options, fallback: B) => Options.Options >(2, (self, fallback) => makeWithDefault(self, fallback)) +/** @internal */ +export const withFallbackConfig: { + (config: Config.Config): (self: Options.Options) => Options.Options + (self: Options.Options, config: Config.Config): Options.Options +} = dual< + (config: Config.Config) => (self: Options.Options) => Options.Options, + (self: Options.Options, config: Config.Config) => Options.Options +>(2, (self, config) => { + if (isInstruction(self) && isWithDefault(self)) { + return makeWithDefault( + withFallbackConfig(self.options, config), + self.fallback as any + ) + } + return makeWithFallbackConfig(self, config) +}) + /** @internal */ export const withDescription = dual< (description: string) => (self: Options.Options) => Options.Options, @@ -667,6 +704,20 @@ const getHelpInternal = (self: Instruction): HelpDoc.HelpDoc => { } ) } + case "WithFallbackConfig": { + return InternalHelpDoc.mapDescriptionList( + getHelpInternal(self.options as Instruction), + (span, block) => [ + span, + InternalHelpDoc.sequence( + block, + InternalHelpDoc.p( + "This option can be set from environment variables." + ) + ) + ] + ) + } } } @@ -694,6 +745,7 @@ const getIdentifierInternal = (self: Instruction): Option.Option => { return getIdentifierInternal(self.argumentOption as Instruction) } case "Map": + case "WithFallbackConfig": case "WithDefault": { return getIdentifierInternal(self.options as Instruction) } @@ -703,7 +755,8 @@ const getIdentifierInternal = (self: Instruction): Option.Option => { const getMinSizeInternal = (self: Instruction): number => { switch (self._tag) { case "Empty": - case "WithDefault": { + case "WithDefault": + case "WithFallbackConfig": { return 0 } case "Single": @@ -742,7 +795,9 @@ const getMaxSizeInternal = (self: Instruction): number => { case "KeyValueMap": { return Number.MAX_SAFE_INTEGER } - case "Map": { + case "Map": + case "WithDefault": + case "WithFallbackConfig": { return getMaxSizeInternal(self.options as Instruction) } case "Both": { @@ -760,9 +815,6 @@ const getMaxSizeInternal = (self: Instruction): number => { const optionsMaxSize = getMaxSizeInternal(self.argumentOption as Instruction) return Math.floor(selfMaxSize * optionsMaxSize) } - case "WithDefault": { - return getMaxSizeInternal(self.options as Instruction) - } } } @@ -801,7 +853,8 @@ const getUsageInternal = (self: Instruction): Usage.Usage => { case "Variadic": { return InternalUsage.repeated(getUsageInternal(self.argumentOption as Instruction)) } - case "WithDefault": { + case "WithDefault": + case "WithFallbackConfig": { return InternalUsage.optional(getUsageInternal(self.options as Instruction)) } } @@ -917,6 +970,17 @@ const makeWithDefault = ( return op } +const makeWithFallbackConfig = ( + options: Options.Options, + config: Config.Config +): Options.Options => { + const op = Object.create(proto) + op._tag = "WithFallbackConfig" + op.options = options + op.config = config + return op +} + const modifySingle = (self: Instruction, f: (single: Single) => Single): Options.Options => { switch (self._tag) { case "Empty": { @@ -949,6 +1013,9 @@ const modifySingle = (self: Instruction, f: (single: Single) => Single): Options case "WithDefault": { return makeWithDefault(modifySingle(self.options as Instruction, f), self.fallback) } + case "WithFallbackConfig": { + return makeWithFallbackConfig(modifySingle(self.options as Instruction, f), self.config) + } } } @@ -967,7 +1034,8 @@ export const getNames = (self: Instruction): ReadonlyArray => { return loop(self.argumentOption as Instruction) } case "Map": - case "WithDefault": { + case "WithDefault": + case "WithFallbackConfig": { return loop(self.options as Instruction) } case "Both": @@ -1001,7 +1069,8 @@ const toParseableInstruction = (self: Instruction): ReadonlyArray Effect.succeed(self.fallback)) ) } + case "WithFallbackConfig": { + return parseInternal(self.options as Instruction, args, config).pipe( + Effect.catchTag( + "MissingValue", + (e) => Effect.mapError(Effect.config(self.config), () => e) + ) + ) + } } } @@ -1293,6 +1370,30 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. ) ) } + case "WithFallbackConfig": { + if (isBoolInternal(self.options as Instruction)) { + return wizardInternal(self.options as Instruction, config) + } + const defaultHelp = InternalHelpDoc.p(`Try load this option from the environment?`) + const message = pipe( + getHelpInternal(self.options as Instruction), + InternalHelpDoc.sequence(defaultHelp) + ) + return InternalSelectPrompt.select({ + message: InternalHelpDoc.toAnsiText(message).trimEnd(), + choices: [ + { title: `Use environment variables`, value: true }, + { title: "Custom", value: false } + ] + }).pipe( + Effect.zipLeft(Console.log()), + Effect.flatMap((useFallback) => + useFallback + ? Effect.succeed(ReadonlyArray.empty()) + : wizardInternal(self.options as Instruction, config) + ) + ) + } } } @@ -1730,7 +1831,8 @@ const getShortDescription = (self: Instruction): string => { return getShortDescription(self.argumentOption as Instruction) } case "Map": - case "WithDefault": { + case "WithDefault": + case "WithFallbackConfig": { return getShortDescription(self.options as Instruction) } } @@ -1760,7 +1862,8 @@ export const getBashCompletions = (self: Instruction): ReadonlyArray => return getBashCompletions(self.argumentOption as Instruction) } case "Map": - case "WithDefault": { + case "WithDefault": + case "WithFallbackConfig": { return getBashCompletions(self.options as Instruction) } case "Both": @@ -1803,7 +1906,8 @@ export const getFishCompletions = (self: Instruction): ReadonlyArray => return getFishCompletions(self.argumentOption as Instruction) } case "Map": - case "WithDefault": { + case "WithDefault": + case "WithFallbackConfig": { return getFishCompletions(self.options as Instruction) } case "Both": @@ -1849,7 +1953,8 @@ export const getZshCompletions = ( return getZshCompletions(self.argumentOption as Instruction, { ...state, multiple: true }) } case "Map": - case "WithDefault": { + case "WithDefault": + case "WithFallbackConfig": { return getZshCompletions(self.options as Instruction, state) } case "Both": { diff --git a/test/Command.test.ts b/test/Command.test.ts index 1f6eb2d..90a5412 100644 --- a/test/Command.test.ts +++ b/test/Command.test.ts @@ -1,14 +1,19 @@ import { Args, Command, Options } from "@effect/cli" import { NodeContext } from "@effect/platform-node" -import { Context, Effect, Layer } from "effect" +import { Config, ConfigProvider, Context, Effect, Layer } from "effect" import { assert, describe, it } from "vitest" const git = Command.make("git", { - verbose: Options.boolean("verbose").pipe(Options.withAlias("v")) + verbose: Options.boolean("verbose").pipe( + Options.withAlias("v"), + Options.withFallbackConfig(Config.boolean("VERBOSE")) + ) }).pipe(Command.withDescription("the stupid content tracker")) const clone = Command.make("clone", { - repository: Args.text({ name: "repository" }) + repository: Args.text({ name: "repository" }).pipe( + Args.withFallbackConfig(Config.string("REPOSITORY")) + ) }, ({ repository }) => Effect.gen(function*(_) { const { log } = yield* _(Messages) @@ -51,31 +56,57 @@ describe("Command", () => { assert.deepStrictEqual(yield* _(messages.messages), []) }).pipe(Effect.provide(EnvLive), Effect.runPromise)) - it.skip("add", () => + it("add", () => Effect.gen(function*(_) { const messages = yield* _(Messages) yield* _(run(["add", "file"])) yield* _(run(["--verbose", "add", "file"])) - yield* _(run(["add", "--verbose", "file"])) assert.deepStrictEqual(yield* _(messages.messages), [ "Adding", - "Adding file", - "Adding" // TODO: probably should be "Adding repo" + "Adding file" ]) }).pipe(Effect.provide(EnvLive), Effect.runPromise)) - it.skip("clone", () => + it("clone", () => Effect.gen(function*(_) { const messages = yield* _(Messages) yield* _(run(["clone", "repo"])) yield* _(run(["--verbose", "clone", "repo"])) - yield* _(run(["clone", "--verbose", "repo"])) assert.deepStrictEqual(yield* _(messages.messages), [ "Cloning", - "Cloning repo", - "Cloning" // TODO: probably should be "Cloning repo" + "Cloning repo" ]) }).pipe(Effect.provide(EnvLive), Effect.runPromise)) + + it("withFallbackConfig Options boolean", () => + Effect.gen(function*(_) { + const messages = yield* _(Messages) + yield* _(run(["clone", "repo"])) + assert.deepStrictEqual(yield* _(messages.messages), [ + "Cloning repo" + ]) + }).pipe( + Effect.withConfigProvider(ConfigProvider.fromMap( + new Map([["VERBOSE", "true"]]) + )), + Effect.provide(EnvLive), + Effect.runPromise + )) + + it("withFallbackConfig Args", () => + Effect.gen(function*(_) { + const messages = yield* _(Messages) + yield* _(run(["clone"])) + assert.deepStrictEqual(yield* _(messages.messages), [ + "Cloning repo" + ]) + }).pipe( + Effect.withConfigProvider(ConfigProvider.fromMap( + new Map([["VERBOSE", "true"], ["REPOSITORY", "repo"]]) + )), + Effect.provide(EnvLive), + Effect.runPromise + )) }) })