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

Add primitive for holding sensitive / confidential values #404

Merged
merged 4 commits into from
Dec 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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")