From a4a4f60ff17a0e1d613119219a2a30b37d253616 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Tue, 19 May 2026 15:29:23 +0200 Subject: [PATCH 1/9] feat: standard schema for validation --- .changeset/social-lands-talk.md | 21 +++ examples/basic/package.json | 1 + examples/basic/standard-schema-validation.ts | 35 ++++ packages/core/package.json | 2 + packages/core/src/index.ts | 2 +- packages/core/src/prompts/prompt.ts | 23 ++- packages/core/test/prompts/prompt.test.ts | 168 ++++++++++++------- packages/prompts/src/autocomplete.ts | 3 +- packages/prompts/src/date.ts | 4 +- packages/prompts/src/password.ts | 3 +- packages/prompts/src/path.ts | 3 +- packages/prompts/src/text.ts | 3 +- pnpm-lock.yaml | 42 +++++ 13 files changed, 244 insertions(+), 66 deletions(-) create mode 100644 .changeset/social-lands-talk.md create mode 100644 examples/basic/standard-schema-validation.ts diff --git a/.changeset/social-lands-talk.md b/.changeset/social-lands-talk.md new file mode 100644 index 00000000..603ffc74 --- /dev/null +++ b/.changeset/social-lands-talk.md @@ -0,0 +1,21 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Adds support for Standard Schema validation + +Prompts accept an optional `validate()` function to validate user input. While a function provides more flexibility and customization over your validation, it can be a bit verbose. To help solve this, there are libraries that provide schema-based validation to make shorthand and type-strict validation substantially easier. + +Libraries following the [Standard Schema specification](https://github.com/standard-schema/standard-schema) are now natively supported. For example, using [Arktype](https://arktype.io/): + +```diff +import { text } from '@clack/prompts'; +import { type } from 'arktype'; + +const name = await text({ + message: 'Enter your name (letters only)', + initialValue: 'John123', // Invalid initial value with numbers ++ validate: type('string.alpha').describe('Name can only contain letters'), +}); +``` diff --git a/examples/basic/package.json b/examples/basic/package.json index d0af04aa..f1d2eb8b 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -5,6 +5,7 @@ "type": "module", "dependencies": { "@clack/prompts": "workspace:*", + "arktype": "^2.2.0", "picocolors": "^1.0.0", "jiti": "^1.17.0" }, diff --git a/examples/basic/standard-schema-validation.ts b/examples/basic/standard-schema-validation.ts new file mode 100644 index 00000000..5bbd9546 --- /dev/null +++ b/examples/basic/standard-schema-validation.ts @@ -0,0 +1,35 @@ +import { setTimeout } from 'node:timers/promises'; +import { isCancel, note, text } from '@clack/prompts'; +import { type } from 'arktype'; + +async function main() { + console.clear(); + + // Example demonstrating the issue with initial value validation + const name = await text({ + message: 'Enter your name (letters only)', + initialValue: 'John123', // Invalid initial value with numbers + validate: type('string.alpha').describe('Name can only contain letters'), + }); + + if (!isCancel(name)) { + note(`Valid name: ${name}`, 'Success'); + } + + await setTimeout(1000); + + // Example with a valid initial value for comparison + const validName = await text({ + message: 'Enter another name (letters only)', + initialValue: 'JohnDoe', // Valid initial value + validate: type('string.alpha').describe('Name can only contain letters'), + }); + + if (!isCancel(validName)) { + note(`Valid name: ${validName}`, 'Success'); + } + + await setTimeout(1000); +} + +main().catch(console.error); diff --git a/packages/core/package.json b/packages/core/package.json index 9f25b0cc..6b32a86c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -56,10 +56,12 @@ "test": "vitest run" }, "dependencies": { + "@standard-schema/spec": "^1.1.0", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" }, "devDependencies": { + "arktype": "^2.2.0", "vitest": "^3.2.4" } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 478095b8..c7758ab3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,7 +12,7 @@ export type { MultiSelectOptions } from './prompts/multi-select.js'; export { default as MultiSelectPrompt } from './prompts/multi-select.js'; export type { PasswordOptions } from './prompts/password.js'; export { default as PasswordPrompt } from './prompts/password.js'; -export type { PromptOptions } from './prompts/prompt.js'; +export type { PromptOptions, PromptOptionsValidate } from './prompts/prompt.js'; export { default as Prompt } from './prompts/prompt.js'; export type { SelectOptions } from './prompts/select.js'; export { default as SelectPrompt } from './prompts/select.js'; diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index c48a5181..709d10ae 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -1,6 +1,7 @@ import { stdin, stdout } from 'node:process'; import readline, { type Key, type ReadLine } from 'node:readline'; import type { Readable, Writable } from 'node:stream'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; import { wrapAnsi } from 'fast-wrap-ansi'; import { cursor, erase } from 'sisteransi'; import type { ClackEvents, ClackState } from '../types.js'; @@ -14,11 +15,15 @@ import { settings, } from '../utils/index.js'; +export type PromptOptionsValidate = + | ((value: TValue | undefined) => string | Error | undefined) + | StandardSchemaV1; + export interface PromptOptions> { render(this: Omit): string | undefined; initialValue?: any; initialUserInput?: string; - validate?: ((value: TValue | undefined) => string | Error | undefined) | undefined; + validate?: PromptOptionsValidate | undefined; input?: Readable; output?: Writable; signal?: AbortSignal; @@ -230,7 +235,21 @@ export default class Prompt { if (key?.name === 'return' && this._shouldSubmit(char, key)) { if (this.opts.validate) { - const problem = this.opts.validate(this.value); + let problem: string | Error | undefined; + + if ('~standard' in this.opts.validate) { + const result = this.opts.validate['~standard'].validate(this.value); + // https://standardschema.dev/schema#how-to-only-allow-synchronous-validation + if (result instanceof Promise) { + throw new TypeError( + 'Schema validation must be synchronous. Update `validate()` and get rid of any asynchronous logic.' + ); + } + problem = result.issues?.at(0)?.message; + } else { + problem = this.opts.validate(this.value); + } + if (problem) { this.error = problem instanceof Error ? problem.message : problem; this.state = 'error'; diff --git a/packages/core/test/prompts/prompt.test.ts b/packages/core/test/prompts/prompt.test.ts index bc4fa5e6..538585fc 100644 --- a/packages/core/test/prompts/prompt.test.ts +++ b/packages/core/test/prompts/prompt.test.ts @@ -1,3 +1,4 @@ +import { type } from 'arktype'; import { cursor } from 'sisteransi'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { default as Prompt } from '../../src/prompts/prompt.js'; @@ -233,82 +234,135 @@ describe('Prompt', () => { expect(instance.state).to.equal('cancel'); }); - test('accepts invalid initial value', () => { - const instance = new Prompt({ - input, - output, - render: () => 'foo', - initialValue: 'invalid', - validate: (value) => (value === 'valid' ? undefined : 'must be valid'), + describe('function validation', () => { + test('accepts invalid initial value', () => { + const instance = new Prompt({ + input, + output, + render: () => 'foo', + initialValue: 'invalid', + validate: (value) => (value === 'valid' ? undefined : 'must be valid'), + }); + instance.prompt(); + + expect(instance.state).to.equal('active'); + expect(instance.error).to.equal(''); }); - instance.prompt(); - expect(instance.state).to.equal('active'); - expect(instance.error).to.equal(''); - }); + test('validates value on return', () => { + const instance = new Prompt({ + input, + output, + render: () => 'foo', + validate: (value) => (value === 'valid' ? undefined : 'must be valid'), + }); + instance.prompt(); - test('validates value on return', () => { - const instance = new Prompt({ - input, - output, - render: () => 'foo', - validate: (value) => (value === 'valid' ? undefined : 'must be valid'), + instance.value = 'invalid'; + + input.emit('keypress', '', { name: 'return' }); + + expect(instance.state).to.equal('error'); + expect(instance.error).to.equal('must be valid'); }); - instance.prompt(); - instance.value = 'invalid'; + test('validates value with Error object', () => { + const instance = new Prompt({ + input, + output, + render: () => 'foo', + validate: (value) => (value === 'valid' ? undefined : new Error('must be valid')), + }); + instance.prompt(); - input.emit('keypress', '', { name: 'return' }); + instance.value = 'invalid'; + input.emit('keypress', '', { name: 'return' }); - expect(instance.state).to.equal('error'); - expect(instance.error).to.equal('must be valid'); - }); + expect(instance.state).to.equal('error'); + expect(instance.error).to.equal('must be valid'); + }); - test('validates value with Error object', () => { - const instance = new Prompt({ - input, - output, - render: () => 'foo', - validate: (value) => (value === 'valid' ? undefined : new Error('must be valid')), + test('validates value with regex validation', () => { + const instance = new Prompt({ + input, + output, + render: () => 'foo', + validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'), + }); + instance.prompt(); + + instance.value = 'Invalid Value $$$'; + input.emit('keypress', '', { name: 'return' }); + + expect(instance.state).to.equal('error'); + expect(instance.error).to.equal('Invalid value'); }); - instance.prompt(); - instance.value = 'invalid'; - input.emit('keypress', '', { name: 'return' }); + test('accepts valid value with regex validation', () => { + const instance = new Prompt({ + input, + output, + render: () => 'foo', + validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'), + }); + instance.prompt(); - expect(instance.state).to.equal('error'); - expect(instance.error).to.equal('must be valid'); + instance.value = 'VALID'; + input.emit('keypress', '', { name: 'return' }); + + expect(instance.state).to.equal('submit'); + expect(instance.error).to.equal(''); + }); }); - test('validates value with regex validation', () => { - const instance = new Prompt({ - input, - output, - render: () => 'foo', - validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'), + describe('standard schema', () => { + test('accepts invalid initial value', () => { + const instance = new Prompt({ + input, + output, + render: () => 'foo', + initialValue: 'invalid', + validate: type("'valid'"), + }); + instance.prompt(); + + expect(instance.state).to.equal('active'); + expect(instance.error).to.equal(''); }); - instance.prompt(); - instance.value = 'Invalid Value $$$'; - input.emit('keypress', '', { name: 'return' }); + test('validates value on return', () => { + const instance = new Prompt({ + input, + output, + render: () => 'foo', + validate: type("'valid'"), + }); + instance.prompt(); - expect(instance.state).to.equal('error'); - expect(instance.error).to.equal('Invalid value'); - }); + instance.value = 'invalid'; - test('accepts valid value with regex validation', () => { - const instance = new Prompt({ - input, - output, - render: () => 'foo', - validate: (value) => (/^[A-Z]+$/.test(value ?? '') ? undefined : 'Invalid value'), + input.emit('keypress', '', { name: 'return' }); + + expect(instance.state).to.equal('error'); + expect(instance.error).to.equal('must be "valid" (was "invalid")'); }); - instance.prompt(); - instance.value = 'VALID'; - input.emit('keypress', '', { name: 'return' }); + test('validates value with Error object', () => { + const instance = new Prompt({ + input, + output, + render: () => 'foo', + validate: type.string.pipe((value) => + value === 'valid' ? undefined : new Error('must be valid') + ), + }); + instance.prompt(); - expect(instance.state).to.equal('submit'); - expect(instance.error).to.equal(''); + instance.value = 'invalid'; + input.emit('keypress', '', { name: 'return' }); + + expect(instance.state).to.equal('error'); + expect(instance.error).to.equal('must be valid'); + }); }); }); diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index 78227000..627d9edc 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -1,4 +1,5 @@ import { styleText } from 'node:util'; +import type { PromptOptionsValidate } from '@clack/core'; import { AutocompletePrompt, settings } from '@clack/core'; import { type CommonOptions, @@ -73,7 +74,7 @@ interface AutocompleteSharedOptions extends CommonOptions { * A function that validates user input. Return a `string` or `Error` to show as a * validation error, or `undefined` to accept the result. */ - validate?: (value: Value | Value[] | undefined) => string | Error | undefined; + validate?: PromptOptionsValidate; /** * Custom filter function to match options against the search input. diff --git a/packages/prompts/src/date.ts b/packages/prompts/src/date.ts index 5f87d197..7b2ee550 100644 --- a/packages/prompts/src/date.ts +++ b/packages/prompts/src/date.ts @@ -1,5 +1,5 @@ import { styleText } from 'node:util'; -import type { DateFormat, State } from '@clack/core'; +import type { DateFormat, PromptOptionsValidate, State } from '@clack/core'; import { DatePrompt, settings } from '@clack/core'; import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; @@ -13,7 +13,7 @@ export interface DateOptions extends CommonOptions { initialValue?: Date; minDate?: Date; maxDate?: Date; - validate?: (value: Date | undefined) => string | Error | undefined; + validate?: PromptOptionsValidate; } export const date = (opts: DateOptions) => { diff --git a/packages/prompts/src/password.ts b/packages/prompts/src/password.ts index 94d96c71..52bf5f75 100644 --- a/packages/prompts/src/password.ts +++ b/packages/prompts/src/password.ts @@ -1,4 +1,5 @@ import { styleText } from 'node:util'; +import type { PromptOptionsValidate } from '@clack/core'; import { PasswordPrompt, settings } from '@clack/core'; import { type CommonOptions, S_BAR, S_BAR_END, S_PASSWORD_MASK, symbol } from './common.js'; @@ -21,7 +22,7 @@ export interface PasswordOptions extends CommonOptions { * A function that validates user input. Return a `string` or `Error` to show as a * validation error, or `undefined` to accept the result. */ - validate?: (value: string | undefined) => string | Error | undefined; + validate?: PromptOptionsValidate; /** * When enabled it causes the input to be cleared if/when validation fails. diff --git a/packages/prompts/src/path.ts b/packages/prompts/src/path.ts index 921a9fda..6ab4052d 100644 --- a/packages/prompts/src/path.ts +++ b/packages/prompts/src/path.ts @@ -1,5 +1,6 @@ import { existsSync, lstatSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; +import type { PromptOptionsValidate } from '@clack/core'; import { autocomplete } from './autocomplete.js'; import type { CommonOptions } from './common.js'; @@ -36,7 +37,7 @@ export interface PathOptions extends CommonOptions { * A function that validates the given path. Return a `string` or `Error` to show as a * validation error, or `undefined` to accept the result. */ - validate?: (value: string | undefined) => string | Error | undefined; + validate?: PromptOptionsValidate; } /** diff --git a/packages/prompts/src/text.ts b/packages/prompts/src/text.ts index 46fae406..fb0aa77c 100644 --- a/packages/prompts/src/text.ts +++ b/packages/prompts/src/text.ts @@ -1,4 +1,5 @@ import { styleText } from 'node:util'; +import type { PromptOptionsValidate } from '@clack/core'; import { settings, TextPrompt } from '@clack/core'; import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; @@ -31,7 +32,7 @@ export interface TextOptions extends CommonOptions { * A function that validates user input. Return a `string` or `Error` to show as a * validation error, or `undefined` to accept the result. */ - validate?: (value: string | undefined) => string | Error | undefined; + validate?: PromptOptionsValidate; } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15cb5248..5c2553d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@clack/prompts': specifier: workspace:* version: link:../../packages/prompts + arktype: + specifier: ^2.2.0 + version: 2.2.0 jiti: specifier: ^1.17.0 version: 1.21.7 @@ -60,6 +63,9 @@ importers: packages/core: dependencies: + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 fast-wrap-ansi: specifier: ^0.2.0 version: 0.2.0 @@ -67,6 +73,9 @@ importers: specifier: ^1.0.5 version: 1.0.5 devDependencies: + arktype: + specifier: ^2.2.0 + version: 2.2.0 vitest: specifier: ^3.2.4 version: 3.2.4(@types/node@20.19.39)(jiti@2.5.0) @@ -101,6 +110,12 @@ importers: packages: + '@ark/schema@0.56.0': + resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==} + + '@ark/util@0.56.0': + resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -700,6 +715,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -773,6 +791,12 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + arkregex@0.0.5: + resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==} + + arktype@2.2.0: + resolution: {integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -1854,6 +1878,12 @@ packages: snapshots: + '@ark/schema@0.56.0': + dependencies: + '@ark/util': 0.56.0 + + '@ark/util@0.56.0': {} + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -2356,6 +2386,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.45.1': optional: true + '@standard-schema/spec@1.1.0': {} + '@trysound/sax@0.2.0': {} '@tybys/wasm-util@0.10.0': @@ -2433,6 +2465,16 @@ snapshots: argparse@2.0.1: {} + arkregex@0.0.5: + dependencies: + '@ark/util': 0.56.0 + + arktype@2.2.0: + dependencies: + '@ark/schema': 0.56.0 + '@ark/util': 0.56.0 + arkregex: 0.0.5 + array-union@2.1.0: {} assertion-error@2.0.1: {} From d071d6d5c059a5984dddc2ce40384a729ae367d7 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Tue, 19 May 2026 18:43:34 +0200 Subject: [PATCH 2/9] fix: types --- packages/core/src/index.ts | 4 +++- packages/core/src/prompts/prompt.ts | 24 ++++-------------------- packages/core/src/utils/validation.ts | 22 ++++++++++++++++++++++ packages/prompts/src/autocomplete.ts | 4 ++-- packages/prompts/src/date.ts | 10 +++++----- packages/prompts/src/password.ts | 4 ++-- packages/prompts/src/path.ts | 7 ++++--- packages/prompts/src/text.ts | 4 ++-- 8 files changed, 44 insertions(+), 35 deletions(-) create mode 100644 packages/core/src/utils/validation.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c7758ab3..4383a3df 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,7 +12,7 @@ export type { MultiSelectOptions } from './prompts/multi-select.js'; export { default as MultiSelectPrompt } from './prompts/multi-select.js'; export type { PasswordOptions } from './prompts/password.js'; export { default as PasswordPrompt } from './prompts/password.js'; -export type { PromptOptions, PromptOptionsValidate } from './prompts/prompt.js'; +export type { PromptOptions } from './prompts/prompt.js'; export { default as Prompt } from './prompts/prompt.js'; export type { SelectOptions } from './prompts/select.js'; export { default as SelectPrompt } from './prompts/select.js'; @@ -24,3 +24,5 @@ export type { ClackState as State } from './types.js'; export { block, getColumns, getRows, isCancel, wrapTextWithPrefix } from './utils/index.js'; export type { ClackSettings } from './utils/settings.js'; export { settings, updateSettings } from './utils/settings.js'; +export type { Validate } from './utils/validation.js'; +export { runValidation } from './utils/validation.js'; diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index 709d10ae..c29d0698 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -1,7 +1,6 @@ import { stdin, stdout } from 'node:process'; import readline, { type Key, type ReadLine } from 'node:readline'; import type { Readable, Writable } from 'node:stream'; -import type { StandardSchemaV1 } from '@standard-schema/spec'; import { wrapAnsi } from 'fast-wrap-ansi'; import { cursor, erase } from 'sisteransi'; import type { ClackEvents, ClackState } from '../types.js'; @@ -14,16 +13,14 @@ import { setRawMode, settings, } from '../utils/index.js'; - -export type PromptOptionsValidate = - | ((value: TValue | undefined) => string | Error | undefined) - | StandardSchemaV1; +import type { Validate } from '../utils/validation.js'; +import { runValidation } from '../utils/validation.js'; export interface PromptOptions> { render(this: Omit): string | undefined; initialValue?: any; initialUserInput?: string; - validate?: PromptOptionsValidate | undefined; + validate?: Validate | undefined; input?: Readable; output?: Writable; signal?: AbortSignal; @@ -235,20 +232,7 @@ export default class Prompt { if (key?.name === 'return' && this._shouldSubmit(char, key)) { if (this.opts.validate) { - let problem: string | Error | undefined; - - if ('~standard' in this.opts.validate) { - const result = this.opts.validate['~standard'].validate(this.value); - // https://standardschema.dev/schema#how-to-only-allow-synchronous-validation - if (result instanceof Promise) { - throw new TypeError( - 'Schema validation must be synchronous. Update `validate()` and get rid of any asynchronous logic.' - ); - } - problem = result.issues?.at(0)?.message; - } else { - problem = this.opts.validate(this.value); - } + const problem = runValidation(this.opts.validate, this.value); if (problem) { this.error = problem instanceof Error ? problem.message : problem; diff --git a/packages/core/src/utils/validation.ts b/packages/core/src/utils/validation.ts new file mode 100644 index 00000000..1d8df101 --- /dev/null +++ b/packages/core/src/utils/validation.ts @@ -0,0 +1,22 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; + +export type Validate = + | ((value: TValue | undefined) => string | Error | undefined) + | StandardSchemaV1; + +export function runValidation( + validate: Validate, + value: TValue | undefined +): string | Error | undefined { + if ('~standard' in validate) { + const result = validate['~standard'].validate(value); + // https://standardschema.dev/schema#how-to-only-allow-synchronous-validation + if (result instanceof Promise) { + throw new TypeError( + 'Schema validation must be synchronous. Update `validate()` and get rid of any asynchronous logic.' + ); + } + return result.issues?.at(0)?.message; + } + return validate(value); +} diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index 627d9edc..dee81839 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -1,5 +1,5 @@ import { styleText } from 'node:util'; -import type { PromptOptionsValidate } from '@clack/core'; +import type { Validate } from '@clack/core'; import { AutocompletePrompt, settings } from '@clack/core'; import { type CommonOptions, @@ -74,7 +74,7 @@ interface AutocompleteSharedOptions extends CommonOptions { * A function that validates user input. Return a `string` or `Error` to show as a * validation error, or `undefined` to accept the result. */ - validate?: PromptOptionsValidate; + validate?: Validate; /** * Custom filter function to match options against the search input. diff --git a/packages/prompts/src/date.ts b/packages/prompts/src/date.ts index 7b2ee550..9f24e8e4 100644 --- a/packages/prompts/src/date.ts +++ b/packages/prompts/src/date.ts @@ -1,6 +1,6 @@ import { styleText } from 'node:util'; -import type { DateFormat, PromptOptionsValidate, State } from '@clack/core'; -import { DatePrompt, settings } from '@clack/core'; +import type { DateFormat, State, Validate } from '@clack/core'; +import { DatePrompt, runValidation, settings } from '@clack/core'; import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; export type { DateFormat }; @@ -13,7 +13,7 @@ export interface DateOptions extends CommonOptions { initialValue?: Date; minDate?: Date; maxDate?: Date; - validate?: PromptOptionsValidate; + validate?: Validate; } export const date = (opts: DateOptions) => { @@ -23,7 +23,7 @@ export const date = (opts: DateOptions) => { validate(value: Date | undefined) { if (value === undefined) { if (opts.defaultValue !== undefined) return undefined; - if (validate) return validate(value); + if (validate) return runValidation(validate, value); return settings.date.messages.required; } const iso = (d: Date) => d.toISOString().slice(0, 10); @@ -33,7 +33,7 @@ export const date = (opts: DateOptions) => { if (opts.maxDate && iso(value) > iso(opts.maxDate)) { return settings.date.messages.beforeMax(opts.maxDate); } - if (validate) return validate(value); + if (validate) return runValidation(validate, value); return undefined; }, render() { diff --git a/packages/prompts/src/password.ts b/packages/prompts/src/password.ts index 52bf5f75..ea97372d 100644 --- a/packages/prompts/src/password.ts +++ b/packages/prompts/src/password.ts @@ -1,5 +1,5 @@ import { styleText } from 'node:util'; -import type { PromptOptionsValidate } from '@clack/core'; +import type { Validate } from '@clack/core'; import { PasswordPrompt, settings } from '@clack/core'; import { type CommonOptions, S_BAR, S_BAR_END, S_PASSWORD_MASK, symbol } from './common.js'; @@ -22,7 +22,7 @@ export interface PasswordOptions extends CommonOptions { * A function that validates user input. Return a `string` or `Error` to show as a * validation error, or `undefined` to accept the result. */ - validate?: PromptOptionsValidate; + validate?: Validate; /** * When enabled it causes the input to be cleared if/when validation fails. diff --git a/packages/prompts/src/path.ts b/packages/prompts/src/path.ts index 6ab4052d..5ed25856 100644 --- a/packages/prompts/src/path.ts +++ b/packages/prompts/src/path.ts @@ -1,6 +1,7 @@ import { existsSync, lstatSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import type { PromptOptionsValidate } from '@clack/core'; +import type { Validate } from '@clack/core'; +import { runValidation } from '@clack/core'; import { autocomplete } from './autocomplete.js'; import type { CommonOptions } from './common.js'; @@ -37,7 +38,7 @@ export interface PathOptions extends CommonOptions { * A function that validates the given path. Return a `string` or `Error` to show as a * validation error, or `undefined` to accept the result. */ - validate?: PromptOptionsValidate; + validate?: Validate; } /** @@ -72,7 +73,7 @@ export const path = (opts: PathOptions) => { return 'Please select a path'; } if (validate) { - return validate(value); + return runValidation(validate, value); } return undefined; }, diff --git a/packages/prompts/src/text.ts b/packages/prompts/src/text.ts index fb0aa77c..1129062b 100644 --- a/packages/prompts/src/text.ts +++ b/packages/prompts/src/text.ts @@ -1,5 +1,5 @@ import { styleText } from 'node:util'; -import type { PromptOptionsValidate } from '@clack/core'; +import type { Validate } from '@clack/core'; import { settings, TextPrompt } from '@clack/core'; import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; @@ -32,7 +32,7 @@ export interface TextOptions extends CommonOptions { * A function that validates user input. Return a `string` or `Error` to show as a * validation error, or `undefined` to accept the result. */ - validate?: PromptOptionsValidate; + validate?: Validate; } /** From b3741536296c955a62965376ea0f09f2291d76d2 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Wed, 20 May 2026 08:40:54 +0200 Subject: [PATCH 3/9] feedback --- examples/basic/standard-schema-validation.ts | 2 +- examples/basic/text-validation.ts | 2 +- packages/core/src/prompts/prompt.ts | 7 ++++++- packages/core/src/utils/validation.ts | 13 +++++++++++++ packages/prompts/src/autocomplete.ts | 5 +++-- packages/prompts/src/date.ts | 6 ++++++ packages/prompts/src/password.ts | 5 +++-- packages/prompts/src/path.ts | 5 +++-- packages/prompts/src/text.ts | 5 +++-- 9 files changed, 39 insertions(+), 11 deletions(-) diff --git a/examples/basic/standard-schema-validation.ts b/examples/basic/standard-schema-validation.ts index 5bbd9546..f89d5a2f 100644 --- a/examples/basic/standard-schema-validation.ts +++ b/examples/basic/standard-schema-validation.ts @@ -32,4 +32,4 @@ async function main() { await setTimeout(1000); } -main().catch(console.error); +await main().catch(console.error); diff --git a/examples/basic/text-validation.ts b/examples/basic/text-validation.ts index 7b928e4c..4cf3f5d9 100644 --- a/examples/basic/text-validation.ts +++ b/examples/basic/text-validation.ts @@ -37,4 +37,4 @@ async function main() { await setTimeout(1000); } -main().catch(console.error); +await main().catch(console.error); diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts index c29d0698..d95442e0 100644 --- a/packages/core/src/prompts/prompt.ts +++ b/packages/core/src/prompts/prompt.ts @@ -20,6 +20,12 @@ export interface PromptOptions> { render(this: Omit): string | undefined; initialValue?: any; initialUserInput?: string; + + /** + * A function or a [Standard Schema](https://github.com/standard-schema/standard-schema) + * that validates user input. Return a `string` or `Error` to show as a validation error, + * or `undefined` to accept the result. + */ validate?: Validate | undefined; input?: Readable; output?: Writable; @@ -233,7 +239,6 @@ export default class Prompt { if (key?.name === 'return' && this._shouldSubmit(char, key)) { if (this.opts.validate) { const problem = runValidation(this.opts.validate, this.value); - if (problem) { this.error = problem instanceof Error ? problem.message : problem; this.state = 'error'; diff --git a/packages/core/src/utils/validation.ts b/packages/core/src/utils/validation.ts index 1d8df101..5570694e 100644 --- a/packages/core/src/utils/validation.ts +++ b/packages/core/src/utils/validation.ts @@ -1,9 +1,21 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; +/** + * Represents the `validate()` option. A function or a + * [Standard Schema](https://github.com/standard-schema/standard-schema) + * that validates user input. Return a `string` or `Error` to show as a + * validation error, or `undefined` to accept the result. + */ export type Validate = | ((value: TValue | undefined) => string | Error | undefined) | StandardSchemaV1; +/** + * Runs the `validate()` option and normalizes the result + * @param validate - The validate option + * @param value - The user input + * @returns string | Error | undefined + */ export function runValidation( validate: Validate, value: TValue | undefined @@ -11,6 +23,7 @@ export function runValidation( if ('~standard' in validate) { const result = validate['~standard'].validate(value); // https://standardschema.dev/schema#how-to-only-allow-synchronous-validation + // TODO: investigate supporting async validation if (result instanceof Promise) { throw new TypeError( 'Schema validation must be synchronous. Update `validate()` and get rid of any asynchronous logic.' diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index dee81839..9bc085f5 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -71,8 +71,9 @@ interface AutocompleteSharedOptions extends CommonOptions { placeholder?: string; /** - * A function that validates user input. Return a `string` or `Error` to show as a - * validation error, or `undefined` to accept the result. + * A function or a [Standard Schema](https://github.com/standard-schema/standard-schema) + * that validates user input. Return a `string` or `Error` to show as a validation error, + * or `undefined` to accept the result. */ validate?: Validate; diff --git a/packages/prompts/src/date.ts b/packages/prompts/src/date.ts index 9f24e8e4..4d0d0ca7 100644 --- a/packages/prompts/src/date.ts +++ b/packages/prompts/src/date.ts @@ -13,6 +13,12 @@ export interface DateOptions extends CommonOptions { initialValue?: Date; minDate?: Date; maxDate?: Date; + + /** + * A function or a [Standard Schema](https://github.com/standard-schema/standard-schema) + * that validates user input. Return a `string` or `Error` to show as a validation error, + * or `undefined` to accept the result. + */ validate?: Validate; } diff --git a/packages/prompts/src/password.ts b/packages/prompts/src/password.ts index ea97372d..3bdd5bdf 100644 --- a/packages/prompts/src/password.ts +++ b/packages/prompts/src/password.ts @@ -19,8 +19,9 @@ export interface PasswordOptions extends CommonOptions { mask?: string; /** - * A function that validates user input. Return a `string` or `Error` to show as a - * validation error, or `undefined` to accept the result. + * A function or a [Standard Schema](https://github.com/standard-schema/standard-schema) + * that validates user input. Return a `string` or `Error` to show as a validation error, + * or `undefined` to accept the result. */ validate?: Validate; diff --git a/packages/prompts/src/path.ts b/packages/prompts/src/path.ts index 5ed25856..6d87486b 100644 --- a/packages/prompts/src/path.ts +++ b/packages/prompts/src/path.ts @@ -35,8 +35,9 @@ export interface PathOptions extends CommonOptions { initialValue?: string; /** - * A function that validates the given path. Return a `string` or `Error` to show as a - * validation error, or `undefined` to accept the result. + * A function or a [Standard Schema](https://github.com/standard-schema/standard-schema) + * that validates user input. Return a `string` or `Error` to show as a validation error, + * or `undefined` to accept the result. */ validate?: Validate; } diff --git a/packages/prompts/src/text.ts b/packages/prompts/src/text.ts index 1129062b..3ca9d975 100644 --- a/packages/prompts/src/text.ts +++ b/packages/prompts/src/text.ts @@ -29,8 +29,9 @@ export interface TextOptions extends CommonOptions { initialValue?: string; /** - * A function that validates user input. Return a `string` or `Error` to show as a - * validation error, or `undefined` to accept the result. + * A function or a [Standard Schema](https://github.com/standard-schema/standard-schema) + * that validates user input. Return a `string` or `Error` to show as a validation error, + * or `undefined` to accept the result. */ validate?: Validate; } From 491371d9e5a53541dae877f6dcfa26b77b142301 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Wed, 20 May 2026 08:43:21 +0200 Subject: [PATCH 4/9] feedback --- .changeset/social-lands-talk.md | 5 ++--- examples/basic/standard-schema-validation.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.changeset/social-lands-talk.md b/.changeset/social-lands-talk.md index 603ffc74..7c7841fc 100644 --- a/.changeset/social-lands-talk.md +++ b/.changeset/social-lands-talk.md @@ -14,8 +14,7 @@ import { text } from '@clack/prompts'; import { type } from 'arktype'; const name = await text({ - message: 'Enter your name (letters only)', - initialValue: 'John123', // Invalid initial value with numbers -+ validate: type('string.alpha').describe('Name can only contain letters'), + message: 'Enter your email', ++ validate: type('string.email').describe('Invalid email'), }); ``` diff --git a/examples/basic/standard-schema-validation.ts b/examples/basic/standard-schema-validation.ts index f89d5a2f..6438dba2 100644 --- a/examples/basic/standard-schema-validation.ts +++ b/examples/basic/standard-schema-validation.ts @@ -7,9 +7,9 @@ async function main() { // Example demonstrating the issue with initial value validation const name = await text({ - message: 'Enter your name (letters only)', - initialValue: 'John123', // Invalid initial value with numbers - validate: type('string.alpha').describe('Name can only contain letters'), + message: 'Enter your email', + initialValue: 'aaa', // Invalid initial value without @ + validate: type('string.email').describe('Invalid email'), }); if (!isCancel(name)) { @@ -20,9 +20,9 @@ async function main() { // Example with a valid initial value for comparison const validName = await text({ - message: 'Enter another name (letters only)', - initialValue: 'JohnDoe', // Valid initial value - validate: type('string.alpha').describe('Name can only contain letters'), + message: 'Enter another email', + initialValue: 'john.doe@example.com', // Valid initial value + validate: type('string.email').describe('Invalid email'), }); if (!isCancel(validName)) { From 5b6579df3823b2c86efd9a39b5694ca74a186cc3 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Wed, 20 May 2026 08:44:14 +0200 Subject: [PATCH 5/9] fix: test --- packages/core/test/prompts/prompt.test.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/core/test/prompts/prompt.test.ts b/packages/core/test/prompts/prompt.test.ts index 538585fc..89c1e37f 100644 --- a/packages/core/test/prompts/prompt.test.ts +++ b/packages/core/test/prompts/prompt.test.ts @@ -346,23 +346,5 @@ describe('Prompt', () => { expect(instance.state).to.equal('error'); expect(instance.error).to.equal('must be "valid" (was "invalid")'); }); - - test('validates value with Error object', () => { - const instance = new Prompt({ - input, - output, - render: () => 'foo', - validate: type.string.pipe((value) => - value === 'valid' ? undefined : new Error('must be valid') - ), - }); - instance.prompt(); - - instance.value = 'invalid'; - input.emit('keypress', '', { name: 'return' }); - - expect(instance.state).to.equal('error'); - expect(instance.error).to.equal('must be valid'); - }); }); }); From fd18aae6106c748ad1bb6208cc391d21e83023f7 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Wed, 20 May 2026 08:51:54 +0200 Subject: [PATCH 6/9] update todo --- packages/core/src/utils/validation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/validation.ts b/packages/core/src/utils/validation.ts index 5570694e..5338d21d 100644 --- a/packages/core/src/utils/validation.ts +++ b/packages/core/src/utils/validation.ts @@ -23,7 +23,7 @@ export function runValidation( if ('~standard' in validate) { const result = validate['~standard'].validate(value); // https://standardschema.dev/schema#how-to-only-allow-synchronous-validation - // TODO: investigate supporting async validation + // TODO: https://github.com/bombshell-dev/clack/issues/92 if (result instanceof Promise) { throw new TypeError( 'Schema validation must be synchronous. Update `validate()` and get rid of any asynchronous logic.' From f318c5bfea5a554999778269c5a7978783c63859 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Wed, 20 May 2026 16:48:29 +0200 Subject: [PATCH 7/9] Update packages/core/src/utils/validation.ts Co-authored-by: James Garbutt <43081j@users.noreply.github.com> --- packages/core/src/utils/validation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/validation.ts b/packages/core/src/utils/validation.ts index 5338d21d..87feb568 100644 --- a/packages/core/src/utils/validation.ts +++ b/packages/core/src/utils/validation.ts @@ -26,7 +26,7 @@ export function runValidation( // TODO: https://github.com/bombshell-dev/clack/issues/92 if (result instanceof Promise) { throw new TypeError( - 'Schema validation must be synchronous. Update `validate()` and get rid of any asynchronous logic.' + 'Schema validation must be synchronous. Update `validate()` and remove any asynchronous logic.' ); } return result.issues?.at(0)?.message; From df2f01a3e4bf29429885f6b73b59b1c132dd2048 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Wed, 20 May 2026 16:49:04 +0200 Subject: [PATCH 8/9] Update packages/core/src/utils/validation.ts Co-authored-by: James Garbutt <43081j@users.noreply.github.com> --- packages/core/src/utils/validation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/validation.ts b/packages/core/src/utils/validation.ts index 87feb568..a02b5aa1 100644 --- a/packages/core/src/utils/validation.ts +++ b/packages/core/src/utils/validation.ts @@ -8,7 +8,7 @@ import type { StandardSchemaV1 } from '@standard-schema/spec'; */ export type Validate = | ((value: TValue | undefined) => string | Error | undefined) - | StandardSchemaV1; + | StandardSchemaV1; /** * Runs the `validate()` option and normalizes the result From 3f77e8ad73a363dea57f443f59a326d266dde468 Mon Sep 17 00:00:00 2001 From: Florian Lefebvre Date: Wed, 20 May 2026 16:51:43 +0200 Subject: [PATCH 9/9] inline standard schema --- packages/core/package.json | 1 - packages/core/src/utils/standard-schema.ts | 78 ++++++++++++++++++++++ packages/core/src/utils/validation.ts | 2 +- pnpm-lock.yaml | 8 --- 4 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/utils/standard-schema.ts diff --git a/packages/core/package.json b/packages/core/package.json index 6b32a86c..0359b105 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -56,7 +56,6 @@ "test": "vitest run" }, "dependencies": { - "@standard-schema/spec": "^1.1.0", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" }, diff --git a/packages/core/src/utils/standard-schema.ts b/packages/core/src/utils/standard-schema.ts new file mode 100644 index 00000000..a1103935 --- /dev/null +++ b/packages/core/src/utils/standard-schema.ts @@ -0,0 +1,78 @@ +// https://standardschema.dev/schema + +/** The Standard Schema interface. */ +export interface StandardSchemaV1 { + /** The Standard Schema properties. */ + readonly "~standard": StandardSchemaV1.Props; +} + +export declare namespace StandardSchemaV1 { + /** The Standard Schema properties interface. */ + export interface Props { + /** The version number of the standard. */ + readonly version: 1; + /** The vendor name of the schema library. */ + readonly vendor: string; + /** Validates unknown input values. */ + readonly validate: ( + value: unknown, + options?: StandardSchemaV1.Options | undefined, + ) => Result | Promise>; + /** Inferred types associated with the schema. */ + readonly types?: Types | undefined; + } + + /** The result interface of the validate function. */ + export type Result = SuccessResult | FailureResult; + + /** The result interface if validation succeeds. */ + export interface SuccessResult { + /** The typed output value. */ + readonly value: Output; + /** A falsy value for `issues` indicates success. */ + readonly issues?: undefined; + } + + export interface Options { + /** Explicit support for additional vendor-specific parameters, if needed. */ + readonly libraryOptions?: Record | undefined; + } + + /** The result interface if validation fails. */ + export interface FailureResult { + /** The issues of failed validation. */ + readonly issues: ReadonlyArray; + } + + /** The issue interface of the failure output. */ + export interface Issue { + /** The error message of the issue. */ + readonly message: string; + /** The path of the issue, if any. */ + readonly path?: ReadonlyArray | undefined; + } + + /** The path segment interface of the issue. */ + export interface PathSegment { + /** The key representing a path segment. */ + readonly key: PropertyKey; + } + + /** The Standard Schema types interface. */ + export interface Types { + /** The input type of the schema. */ + readonly input: Input; + /** The output type of the schema. */ + readonly output: Output; + } + + /** Infers the input type of a Standard Schema. */ + export type InferInput = NonNullable< + Schema["~standard"]["types"] + >["input"]; + + /** Infers the output type of a Standard Schema. */ + export type InferOutput = NonNullable< + Schema["~standard"]["types"] + >["output"]; +} diff --git a/packages/core/src/utils/validation.ts b/packages/core/src/utils/validation.ts index a02b5aa1..d4cf3c95 100644 --- a/packages/core/src/utils/validation.ts +++ b/packages/core/src/utils/validation.ts @@ -1,4 +1,4 @@ -import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { StandardSchemaV1 } from './standard-schema.js'; /** * Represents the `validate()` option. A function or a diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c2553d7..b8205001 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,9 +63,6 @@ importers: packages/core: dependencies: - '@standard-schema/spec': - specifier: ^1.1.0 - version: 1.1.0 fast-wrap-ansi: specifier: ^0.2.0 version: 0.2.0 @@ -715,9 +712,6 @@ packages: cpu: [x64] os: [win32] - '@standard-schema/spec@1.1.0': - resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} @@ -2386,8 +2380,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.45.1': optional: true - '@standard-schema/spec@1.1.0': {} - '@trysound/sax@0.2.0': {} '@tybys/wasm-util@0.10.0':