Skip to content
This repository has been archived by the owner on Jul 16, 2024. It is now read-only.

Commit

Permalink
Add primitive for holding sensitive / confidential values (#404)
Browse files Browse the repository at this point in the history
  • Loading branch information
IMax153 authored Dec 7, 2023
1 parent 07b3529 commit 70fc225
Show file tree
Hide file tree
Showing 9 changed files with 118 additions and 20 deletions.
5 changes: 5 additions & 0 deletions .changeset/neat-ears-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/cli": patch
---

remove `"type"` option from `Prompt.text` and add `Prompt.password` and `Prompt.hidden` which return `Secret`
5 changes: 2 additions & 3 deletions examples/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -52,7 +51,7 @@ const prompt = Prompt.all([
confirmPrompt,
datePrompt,
numberPrompt,
textPrompt,
passwordPrompt,
togglePrompt
])

Expand Down
11 changes: 11 additions & 0 deletions src/Args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -319,6 +320,16 @@ export const path: (config?: Args.PathArgsConfig) => Args<string> = InternalArgs
*/
export const repeated: <A>(self: Args<A>) => Args<ReadonlyArray<A>> = 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<Secret> = InternalArgs.secret

/**
* Creates a text argument.
*
Expand Down
7 changes: 7 additions & 0 deletions src/Options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -266,6 +267,12 @@ export const keyValueMap: (option: string | Options<string>) => Options<HashMap<
*/
export const none: Options<void> = InternalOptions.none

/**
* @since 1.0.0
* @category constructors
*/
export const secret: (name: string) => Options<Secret> = InternalOptions.secret

/**
* @since 1.0.0
* @category constructors
Expand Down
17 changes: 13 additions & 4 deletions src/Prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -426,6 +423,12 @@ export const flatMap: {
*/
export const float: (options: Prompt.FloatOptions) => Prompt<number> = InternalNumberPrompt.float

/**
* @since 1.0.0
* @category constructors
*/
export const hidden: (options: Prompt.TextOptions) => Prompt<Secret> = InternalTextPrompt.hidden

/**
* @since 1.0.0
* @category constructors
Expand All @@ -449,6 +452,12 @@ export const map: {
<Output, Output2>(self: Prompt<Output>, f: (output: Output) => Output2): Prompt<Output2>
} = InternalPrompt.map

/**
* @since 1.0.0
* @category constructors
*/
export const password: (options: Prompt.TextOptions) => Prompt<Secret> = InternalTextPrompt.password

/**
* Executes the specified `Prompt`.
*
Expand Down
7 changes: 7 additions & 0 deletions src/internal/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -223,6 +224,12 @@ export const path = (config: Args.Args.PathArgsConfig = {}): Args.Args<string> =
InternalPrimitive.path("either", config.exists || "either")
)

/** @internal */
export const secret = (
config: Args.Args.BaseArgsConfig = {}
): Args.Args<Secret.Secret> =>
makeSingle(Option.fromNullable(config.name), InternalPrimitive.secret)

/** @internal */
export const text = (config: Args.Args.BaseArgsConfig = {}): Args.Args<string> =>
makeSingle(Option.fromNullable(config.name), InternalPrimitive.text)
Expand Down
5 changes: 5 additions & 0 deletions src/internal/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -331,6 +332,10 @@ export const none: Options.Options<void> = (() => {
return op
})()

/** @internal */
export const secret = (name: string): Options.Options<Secret.Secret> =>
makeSingle(name, ReadonlyArray.empty(), InternalPrimitive.secret)

/** @internal */
export const text = (name: string): Options.Options<string> =>
makeSingle(name, ReadonlyArray.empty(), InternalPrimitive.text)
Expand Down
41 changes: 37 additions & 4 deletions src/internal/primitive.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -53,6 +50,7 @@ export type Instruction =
| Float
| Integer
| Path
| Secret
| Text

/** @internal */
Expand Down Expand Up @@ -86,6 +84,9 @@ export interface Path extends
}>
{}

/** @internal */
export interface Secret extends Op<"Secret", {}> {}

/** @internal */
export interface Text extends Op<"Text", {}> {}

Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -189,6 +193,13 @@ export const path = (
return op
}

/** @internal */
export const secret: Primitive.Primitive<EffectSecret.Secret> = (() => {
const op = Object.create(proto)
op._tag = "Secret"
return op
})()

/** @internal */
export const text: Primitive.Primitive<string> = (() => {
const op = Object.create(proto)
Expand Down Expand Up @@ -261,6 +272,7 @@ const getChoicesInternal = (self: Instruction): Option.Option<string> => {
case "Float":
case "Integer":
case "Path":
case "Secret":
case "Text": {
return Option.none()
}
Expand Down Expand Up @@ -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.")
}
Expand Down Expand Up @@ -352,6 +367,9 @@ const getTypeNameInternal = (self: Instruction): string => {
}
return self.pathType
}
case "Secret": {
return "secret"
}
case "Text": {
return "text"
}
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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)
Expand All @@ -576,6 +606,7 @@ export const getBashCompletions = (self: Instruction): string => {
case "DateTime":
case "Float":
case "Integer":
case "Secret":
case "Text": {
return "$(compgen -f \"${cur}\")"
}
Expand Down Expand Up @@ -617,6 +648,7 @@ export const getFishCompletions = (self: Instruction): ReadonlyArray<string> =>
case "DateTime":
case "Float":
case "Integer":
case "Secret":
case "Text": {
return ReadonlyArray.make("-r", "-f")
}
Expand Down Expand Up @@ -695,6 +727,7 @@ export const getZshCompletions = (self: Instruction): string => {
}
}
}
case "Secret":
case "Text": {
return ""
}
Expand Down
40 changes: 31 additions & 9 deletions src/internal/prompt/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Prompt.Prompt.TextOptions> {
/**
* The type of the text option.
*/
readonly type: "hidden" | "password" | "text"
}

interface State {
readonly cursor: number
readonly offset: number
Expand All @@ -22,7 +30,7 @@ const renderBeep = Doc.render(Doc.beep, { style: "pretty" })

const renderClearScreen = (
prevState: Option.Option<State>,
options: Required<Prompt.Prompt.TextOptions>,
options: Options,
columns: number
): Doc.AnsiDoc => {
// Erase the current line and place the cursor in column one
Expand Down Expand Up @@ -54,7 +62,7 @@ const renderClearScreen = (

const renderInput = (
nextState: State,
options: Required<Prompt.Prompt.TextOptions>,
options: Options,
submitted: boolean
): Doc.AnsiDoc => {
const annotation = Option.match(nextState.error, {
Expand Down Expand Up @@ -103,7 +111,7 @@ const renderOutput = (
nextState: State,
leadingSymbol: Doc.AnsiDoc,
trailingSymbol: Doc.AnsiDoc,
options: Required<Prompt.Prompt.TextOptions>,
options: Options,
submitted: boolean = false
): Doc.AnsiDoc => {
const annotateLine = (line: string): Doc.AnsiDoc => pipe(Doc.text(line), Doc.annotate(Ansi.bold))
Expand All @@ -126,7 +134,7 @@ const renderOutput = (
const renderNextFrame = (
prevState: Option.Option<State>,
nextState: State,
options: Required<Prompt.Prompt.TextOptions>
options: Options
): Effect.Effect<Terminal.Terminal, never, string> =>
Effect.gen(function*(_) {
const terminal = yield* _(Terminal.Terminal)
Expand All @@ -149,7 +157,7 @@ const renderNextFrame = (

const renderSubmission = (
nextState: State,
options: Required<Prompt.Prompt.TextOptions>
options: Options
) =>
Effect.gen(function*(_) {
const terminal = yield* _(Terminal.Terminal)
Expand Down Expand Up @@ -232,11 +240,13 @@ const initialState: State = {
error: Option.none()
}

/** @internal */
export const text = (options: Prompt.Prompt.TextOptions): Prompt.Prompt<string> => {
const opts: Required<Prompt.Prompt.TextOptions> = {
const basePrompt = (
options: Prompt.Prompt.TextOptions,
type: Options["type"]
): Prompt.Prompt<string> => {
const opts: Options = {
default: "",
type: "text",
type,
validate: Effect.succeed,
...options
}
Expand Down Expand Up @@ -285,3 +295,15 @@ export const text = (options: Prompt.Prompt.TextOptions): Prompt.Prompt<string>
}
)
}

/** @internal */
export const hidden = (options: Prompt.Prompt.TextOptions): Prompt.Prompt<Secret.Secret> =>
basePrompt(options, "hidden").pipe(InternalPrompt.map(Secret.fromString))

/** @internal */
export const password = (options: Prompt.Prompt.TextOptions): Prompt.Prompt<Secret.Secret> =>
basePrompt(options, "password").pipe(InternalPrompt.map(Secret.fromString))

/** @internal */
export const text = (options: Prompt.Prompt.TextOptions): Prompt.Prompt<string> =>
basePrompt(options, "text")

0 comments on commit 70fc225

Please sign in to comment.