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 b7f2508..34b6144 100644 --- a/src/Args.ts +++ b/src/Args.ts @@ -9,6 +9,7 @@ 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" @@ -319,6 +320,16 @@ export const path: (config?: Args.PathArgsConfig) => Args = InternalArgs */ export const repeated: (self: Args) => Args> = InternalArgs.repeated +/** + * Creates a text argument. + * + * Can optionally provide a custom argument name (defaults to `"secret"`). + * + * @since 1.0.0 + * @category constructors + */ +export const secret: (config?: Args.BaseArgsConfig) => Args = InternalArgs.secret + /** * Creates a text argument. * diff --git a/src/Options.ts b/src/Options.ts index d1e999a..b57053c 100644 --- a/src/Options.ts +++ b/src/Options.ts @@ -10,6 +10,7 @@ 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" @@ -266,6 +267,12 @@ export const keyValueMap: (option: string | Options) => Options = InternalOptions.none +/** + * @since 1.0.0 + * @category constructors + */ +export const secret: (name: string) => Options = InternalOptions.secret + /** * @since 1.0.0 * @category constructors 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 9181829..2e44be6 100644 --- a/src/internal/args.ts +++ b/src/internal/args.ts @@ -9,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" @@ -223,6 +224,12 @@ export const path = (config: Args.Args.PathArgsConfig = {}): Args.Args = InternalPrimitive.path("either", config.exists || "either") ) +/** @internal */ +export const secret = ( + config: Args.Args.BaseArgsConfig = {} +): Args.Args => + makeSingle(Option.fromNullable(config.name), InternalPrimitive.secret) + /** @internal */ export const text = (config: Args.Args.BaseArgsConfig = {}): Args.Args => makeSingle(Option.fromNullable(config.name), InternalPrimitive.text) diff --git a/src/internal/options.ts b/src/internal/options.ts index 53b4ca8..aaeb8f4 100644 --- a/src/internal/options.ts +++ b/src/internal/options.ts @@ -11,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" @@ -331,6 +332,10 @@ export const none: Options.Options = (() => { return op })() +/** @internal */ +export const secret = (name: string): Options.Options => + makeSingle(name, ReadonlyArray.empty(), InternalPrimitive.secret) + /** @internal */ export const text = (name: string): Options.Options => makeSingle(name, ReadonlyArray.empty(), InternalPrimitive.text) diff --git a/src/internal/primitive.ts b/src/internal/primitive.ts index 3b7687d..11afdc8 100644 --- a/src/internal/primitive.ts +++ b/src/internal/primitive.ts @@ -1,14 +1,11 @@ import * as FileSystem from "@effect/platform/FileSystem" -// import * as AnsiRender from "@effect/printer-ansi/AnsiRender" -// import * as AnsiStyle from "@effect/printer-ansi/AnsiStyle" -// import * as Color from "@effect/printer-ansi/Color" -// import * as Doc from "@effect/printer/Doc" import * as Schema from "@effect/schema/Schema" 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" @@ -53,6 +50,7 @@ export type Instruction = | Float | Integer | Path + | Secret | Text /** @internal */ @@ -86,6 +84,9 @@ export interface Path extends }> {} +/** @internal */ +export interface Secret extends Op<"Secret", {}> {} + /** @internal */ export interface Text extends Op<"Text", {}> {} @@ -119,6 +120,9 @@ export const isIntegerType = (self: Instruction): self is Integer => self._tag = /** @internal */ export const isPathType = (self: Instruction): self is Path => self._tag === "Path" +/** @internal */ +export const isSecretType = (self: Instruction): self is Path => self._tag === "Path" + /** @internal */ export const isTextType = (self: Instruction): self is Text => self._tag === "Text" @@ -189,6 +193,13 @@ export const path = ( return op } +/** @internal */ +export const secret: Primitive.Primitive = (() => { + const op = Object.create(proto) + op._tag = "Secret" + return op +})() + /** @internal */ export const text: Primitive.Primitive = (() => { const op = Object.create(proto) @@ -261,6 +272,7 @@ const getChoicesInternal = (self: Instruction): Option.Option => { case "Float": case "Integer": case "Path": + case "Secret": case "Text": { return Option.none() } @@ -323,6 +335,9 @@ const getHelpInternal = (self: Instruction): Span.Span => { `('${self.pathType}') and path existence ('${self.pathExists}')` ) } + case "Secret": { + return InternalSpan.text("A user-defined piece of text that is confidential.") + } case "Text": { return InternalSpan.text("A user-defined piece of text.") } @@ -352,6 +367,9 @@ const getTypeNameInternal = (self: Instruction): string => { } return self.pathType } + case "Secret": { + return "secret" + } case "Text": { return "text" } @@ -432,6 +450,11 @@ const validateInternal = ( ) }) } + case "Secret": { + return attempt(value, getTypeNameInternal(self), Schema.parse(Schema.string)).pipe( + Effect.map((value) => EffectSecret.fromString(value)) + ) + } case "Text": { return attempt(value, getTypeNameInternal(self), Schema.parse(Schema.string)) } @@ -553,6 +576,13 @@ const wizardInternal = (self: Instruction, help: HelpDoc.HelpDoc): Prompt.Prompt message: InternalHelpDoc.toAnsiText(message).trimEnd() }) } + case "Secret": { + const primitiveHelp = InternalHelpDoc.p("Enter some text (value will be hidden)") + const message = InternalHelpDoc.sequence(help, primitiveHelp) + return InternalTextPrompt.hidden({ + message: InternalHelpDoc.toAnsiText(message).trimEnd() + }) + } case "Text": { const primitiveHelp = InternalHelpDoc.p("Enter some text") const message = InternalHelpDoc.sequence(help, primitiveHelp) @@ -576,6 +606,7 @@ export const getBashCompletions = (self: Instruction): string => { case "DateTime": case "Float": case "Integer": + case "Secret": case "Text": { return "$(compgen -f \"${cur}\")" } @@ -617,6 +648,7 @@ export const getFishCompletions = (self: Instruction): ReadonlyArray => case "DateTime": case "Float": case "Integer": + case "Secret": case "Text": { return ReadonlyArray.make("-r", "-f") } @@ -695,6 +727,7 @@ export const getZshCompletions = (self: Instruction): string => { } } } + case "Secret": case "Text": { return "" } 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")