From 82db70640898330129b18a539e78fa201fcf8f65 Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Thu, 7 Dec 2023 14:10:57 -0500 Subject: [PATCH] add Prompt.password and Prompt.hidden --- .changeset/neat-ears-kneel.md | 5 +++++ examples/prompt.ts | 5 ++--- src/Args.ts | 4 ++-- src/Options.ts | 4 ++-- src/Prompt.ts | 17 +++++++++++---- src/internal/args.ts | 4 ++-- src/internal/options.ts | 4 ++-- src/internal/primitive.ts | 11 +++++----- src/internal/prompt/text.ts | 40 +++++++++++++++++++++++++++-------- 9 files changed, 64 insertions(+), 30 deletions(-) create mode 100644 .changeset/neat-ears-kneel.md diff --git a/.changeset/neat-ears-kneel.md b/.changeset/neat-ears-kneel.md new file mode 100644 index 0000000..41f1e82 --- /dev/null +++ b/.changeset/neat-ears-kneel.md @@ -0,0 +1,5 @@ +--- +"@effect/cli": patch +--- + +remove `"type"` option from `Prompt.text` and add `Prompt.password` and `Prompt.hidden` which return `Secret` diff --git a/examples/prompt.ts b/examples/prompt.ts index 5cebd65..02ab737 100644 --- a/examples/prompt.ts +++ b/examples/prompt.ts @@ -32,9 +32,8 @@ const numberPrompt = Prompt.float({ validate: (n) => n > 0 ? Effect.succeed(n) : Effect.fail("must be greater than 0") }) -const textPrompt = Prompt.text({ +const passwordPrompt = Prompt.password({ message: "Enter your password: ", - type: "password", validate: (value) => value.length === 0 ? Effect.fail("Password cannot be empty") @@ -52,7 +51,7 @@ const prompt = Prompt.all([ confirmPrompt, datePrompt, numberPrompt, - textPrompt, + passwordPrompt, togglePrompt ]) diff --git a/src/Args.ts b/src/Args.ts index 3d10b93..34b6144 100644 --- a/src/Args.ts +++ b/src/Args.ts @@ -4,12 +4,12 @@ import type { FileSystem } from "@effect/platform/FileSystem" import type { QuitException, Terminal } from "@effect/platform/Terminal" import type { Config } from "effect/Config" -import type { ConfigSecret } from "effect/ConfigSecret" import type { Effect } from "effect/Effect" import type { Either } from "effect/Either" import type { Option } from "effect/Option" import type { Pipeable } from "effect/Pipeable" import type { NonEmptyReadonlyArray } from "effect/ReadonlyArray" +import type { Secret } from "effect/Secret" import type { CliConfig } from "./CliConfig.js" import type { HelpDoc } from "./HelpDoc.js" import * as InternalArgs from "./internal/args.js" @@ -328,7 +328,7 @@ export const repeated: (self: Args) => Args> = InternalAr * @since 1.0.0 * @category constructors */ -export const secret: (config?: Args.BaseArgsConfig) => Args = InternalArgs.secret +export const secret: (config?: Args.BaseArgsConfig) => Args = InternalArgs.secret /** * Creates a text argument. diff --git a/src/Options.ts b/src/Options.ts index 2cd1a42..b57053c 100644 --- a/src/Options.ts +++ b/src/Options.ts @@ -4,13 +4,13 @@ import type { FileSystem } from "@effect/platform/FileSystem" import type { QuitException, Terminal } from "@effect/platform/Terminal" import type { Config } from "effect/Config" -import type { ConfigSecret } from "effect/ConfigSecret" import type { Effect } from "effect/Effect" import type { Either } from "effect/Either" import type { HashMap } from "effect/HashMap" import type { Option } from "effect/Option" import type { Pipeable } from "effect/Pipeable" import type { NonEmptyReadonlyArray } from "effect/ReadonlyArray" +import type { Secret } from "effect/Secret" import type { CliConfig } from "./CliConfig.js" import type { HelpDoc } from "./HelpDoc.js" import * as InternalOptions from "./internal/options.js" @@ -271,7 +271,7 @@ export const none: Options = InternalOptions.none * @since 1.0.0 * @category constructors */ -export const secret: (name: string) => Options = InternalOptions.secret +export const secret: (name: string) => Options = InternalOptions.secret /** * @since 1.0.0 diff --git a/src/Prompt.ts b/src/Prompt.ts index 6d8e1ca..80dfe64 100644 --- a/src/Prompt.ts +++ b/src/Prompt.ts @@ -5,6 +5,7 @@ import type { QuitException, Terminal, UserInput } from "@effect/platform/Termin import type { Effect } from "effect/Effect" import type { Option } from "effect/Option" import type { Pipeable } from "effect/Pipeable" +import type { Secret } from "effect/Secret" import * as InternalPrompt from "./internal/prompt.js" import * as InternalConfirmPrompt from "./internal/prompt/confirm.js" import * as InternalDatePrompt from "./internal/prompt/date.js" @@ -284,10 +285,6 @@ export declare namespace Prompt { * The message to display in the prompt. */ readonly message: string - /** - * The type of the text option. - */ - readonly type?: "hidden" | "password" | "text" /** * The default value of the text option. */ @@ -426,6 +423,12 @@ export const flatMap: { */ export const float: (options: Prompt.FloatOptions) => Prompt = InternalNumberPrompt.float +/** + * @since 1.0.0 + * @category constructors + */ +export const hidden: (options: Prompt.TextOptions) => Prompt = InternalTextPrompt.hidden + /** * @since 1.0.0 * @category constructors @@ -449,6 +452,12 @@ export const map: { (self: Prompt, f: (output: Output) => Output2): Prompt } = InternalPrompt.map +/** + * @since 1.0.0 + * @category constructors + */ +export const password: (options: Prompt.TextOptions) => Prompt = InternalTextPrompt.password + /** * Executes the specified `Prompt`. * diff --git a/src/internal/args.ts b/src/internal/args.ts index 17413a7..2e44be6 100644 --- a/src/internal/args.ts +++ b/src/internal/args.ts @@ -1,7 +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 type * as ConfigSecret from "effect/ConfigSecret" import * as Console from "effect/Console" import * as Effect from "effect/Effect" import * as Either from "effect/Either" @@ -10,6 +9,7 @@ import * as Option from "effect/Option" import { pipeArguments } from "effect/Pipeable" import * as ReadonlyArray from "effect/ReadonlyArray" import * as Ref from "effect/Ref" +import type * as Secret from "effect/Secret" import type * as Args from "../Args.js" import type * as CliConfig from "../CliConfig.js" import type * as HelpDoc from "../HelpDoc.js" @@ -227,7 +227,7 @@ export const path = (config: Args.Args.PathArgsConfig = {}): Args.Args = /** @internal */ export const secret = ( config: Args.Args.BaseArgsConfig = {} -): Args.Args => +): Args.Args => makeSingle(Option.fromNullable(config.name), InternalPrimitive.secret) /** @internal */ diff --git a/src/internal/options.ts b/src/internal/options.ts index e5b4f75..aaeb8f4 100644 --- a/src/internal/options.ts +++ b/src/internal/options.ts @@ -1,7 +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 type * as ConfigSecret from "effect/ConfigSecret" import * as Console from "effect/Console" import * as Effect from "effect/Effect" import * as Either from "effect/Either" @@ -12,6 +11,7 @@ import * as Order from "effect/Order" import { pipeArguments } from "effect/Pipeable" import * as ReadonlyArray from "effect/ReadonlyArray" import * as Ref from "effect/Ref" +import type * as Secret from "effect/Secret" import type * as CliConfig from "../CliConfig.js" import type * as HelpDoc from "../HelpDoc.js" import type * as Options from "../Options.js" @@ -333,7 +333,7 @@ export const none: Options.Options = (() => { })() /** @internal */ -export const secret = (name: string): Options.Options => +export const secret = (name: string): Options.Options => makeSingle(name, ReadonlyArray.empty(), InternalPrimitive.secret) /** @internal */ diff --git a/src/internal/primitive.ts b/src/internal/primitive.ts index d478d59..11afdc8 100644 --- a/src/internal/primitive.ts +++ b/src/internal/primitive.ts @@ -1,11 +1,11 @@ import * as FileSystem from "@effect/platform/FileSystem" import * as Schema from "@effect/schema/Schema" -import * as ConfigSecret from "effect/ConfigSecret" import * as Effect from "effect/Effect" import { dual, pipe } from "effect/Function" import * as Option from "effect/Option" import { pipeArguments } from "effect/Pipeable" import * as ReadonlyArray from "effect/ReadonlyArray" +import * as EffectSecret from "effect/Secret" import type * as CliConfig from "../CliConfig.js" import type * as HelpDoc from "../HelpDoc.js" import type * as Span from "../HelpDoc/Span.js" @@ -194,7 +194,7 @@ export const path = ( } /** @internal */ -export const secret: Primitive.Primitive = (() => { +export const secret: Primitive.Primitive = (() => { const op = Object.create(proto) op._tag = "Secret" return op @@ -452,7 +452,7 @@ const validateInternal = ( } case "Secret": { return attempt(value, getTypeNameInternal(self), Schema.parse(Schema.string)).pipe( - Effect.map((value) => ConfigSecret.fromString(value)) + Effect.map((value) => EffectSecret.fromString(value)) ) } case "Text": { @@ -579,10 +579,9 @@ const wizardInternal = (self: Instruction, help: HelpDoc.HelpDoc): Prompt.Prompt case "Secret": { const primitiveHelp = InternalHelpDoc.p("Enter some text (value will be hidden)") const message = InternalHelpDoc.sequence(help, primitiveHelp) - return InternalTextPrompt.text({ - type: "password", + return InternalTextPrompt.hidden({ message: InternalHelpDoc.toAnsiText(message).trimEnd() - }).pipe(InternalPrompt.map((value) => ConfigSecret.fromString(value))) + }) } case "Text": { const primitiveHelp = InternalHelpDoc.p("Enter some text") diff --git a/src/internal/prompt/text.ts b/src/internal/prompt/text.ts index 97bc49f..4d0fbeb 100644 --- a/src/internal/prompt/text.ts +++ b/src/internal/prompt/text.ts @@ -6,11 +6,19 @@ import * as Effect from "effect/Effect" import { pipe } from "effect/Function" import * as Option from "effect/Option" import * as ReadonlyArray from "effect/ReadonlyArray" +import * as Secret from "effect/Secret" import type * as Prompt from "../../Prompt.js" import * as InternalPrompt from "../prompt.js" import * as InternalPromptAction from "./action.js" import * as InternalAnsiUtils from "./ansi-utils.js" +interface Options extends Required { + /** + * The type of the text option. + */ + readonly type: "hidden" | "password" | "text" +} + interface State { readonly cursor: number readonly offset: number @@ -22,7 +30,7 @@ const renderBeep = Doc.render(Doc.beep, { style: "pretty" }) const renderClearScreen = ( prevState: Option.Option, - options: Required, + options: Options, columns: number ): Doc.AnsiDoc => { // Erase the current line and place the cursor in column one @@ -54,7 +62,7 @@ const renderClearScreen = ( const renderInput = ( nextState: State, - options: Required, + options: Options, submitted: boolean ): Doc.AnsiDoc => { const annotation = Option.match(nextState.error, { @@ -103,7 +111,7 @@ const renderOutput = ( nextState: State, leadingSymbol: Doc.AnsiDoc, trailingSymbol: Doc.AnsiDoc, - options: Required, + options: Options, submitted: boolean = false ): Doc.AnsiDoc => { const annotateLine = (line: string): Doc.AnsiDoc => pipe(Doc.text(line), Doc.annotate(Ansi.bold)) @@ -126,7 +134,7 @@ const renderOutput = ( const renderNextFrame = ( prevState: Option.Option, nextState: State, - options: Required + options: Options ): Effect.Effect => Effect.gen(function*(_) { const terminal = yield* _(Terminal.Terminal) @@ -149,7 +157,7 @@ const renderNextFrame = ( const renderSubmission = ( nextState: State, - options: Required + options: Options ) => Effect.gen(function*(_) { const terminal = yield* _(Terminal.Terminal) @@ -232,11 +240,13 @@ const initialState: State = { error: Option.none() } -/** @internal */ -export const text = (options: Prompt.Prompt.TextOptions): Prompt.Prompt => { - const opts: Required = { +const basePrompt = ( + options: Prompt.Prompt.TextOptions, + type: Options["type"] +): Prompt.Prompt => { + const opts: Options = { default: "", - type: "text", + type, validate: Effect.succeed, ...options } @@ -285,3 +295,15 @@ export const text = (options: Prompt.Prompt.TextOptions): Prompt.Prompt } ) } + +/** @internal */ +export const hidden = (options: Prompt.Prompt.TextOptions): Prompt.Prompt => + basePrompt(options, "hidden").pipe(InternalPrompt.map(Secret.fromString)) + +/** @internal */ +export const password = (options: Prompt.Prompt.TextOptions): Prompt.Prompt => + basePrompt(options, "password").pipe(InternalPrompt.map(Secret.fromString)) + +/** @internal */ +export const text = (options: Prompt.Prompt.TextOptions): Prompt.Prompt => + basePrompt(options, "text")