diff --git a/CHANGELOG.md b/CHANGELOG.md index 728cd7b..834f5ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Added `ConfigEditor` and `editConfig` for editing JSON configuration files setting by setting, with defaults, invalid-value markers, revert, and a confirmation before discarding changes. `loadConfig` and `saveConfig` read and atomically write the files. +- Added `Wizard` and `runWizard` for multi-step setup flows with conditional steps, a progress row, and a review screen where any step can be changed. +- Esc in `askAll` and `PromptList` now returns to the previous question instead of cancelling. - Added `text`, `number`, `confirm`, `select`, and `multiselect` prompts with validation, including asynchronous checks, masked input, and typed answers through `defineFields`. - Added `ask`, `askAll`, `Prompt`, and `PromptList`, and an interactive demonstration of a project setup flow. - Added `FormFrame`, the `runForm` renderer with cancellation, Relay themes, and the `relay-forms` demonstration command. diff --git a/README.md b/README.md index d01692a..2f87d33 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ npx tsx src/cli.tsx --demo --snapshot ## Ask questions -`askAll` asks a list of fields in order and resolves with the answers, keyed by field name. Answered questions stay in the terminal as a summary. +`askAll` asks a list of fields in order and resolves with the answers, keyed by field name. Answered questions stay in the terminal as a summary, and Esc returns to the previous question. ```ts import {FormCancelledError, askAll, defineFields} from '@relay-cli/forms'; @@ -45,6 +45,41 @@ try { `ask` asks a single field. Fields can be `text`, `number`, `confirm`, `select`, or `multiselect`, and each can validate its answer, including with an asynchronous check. See [the prompt guide](./docs/prompts.md). +## Multi-step wizards + +`runWizard` groups fields into steps. Steps can depend on earlier answers, Esc moves back through questions and steps, and a review screen lets the person change any step before the wizard resolves. + +```ts +import {runWizard} from '@relay-cli/forms'; + +const answers = await runWizard( + [ + {id: 'project', title: 'Project', fields: [nameField, kindField]}, + {id: 'service', title: 'Service', when: (a) => a.kind === 'service', fields: [portField]}, + {id: 'publish', title: 'Publish', fields: [tokenField, gitField]}, + ], + {title: 'Create a project'}, +); +``` + +Run `npm run demo -- wizard` to try it. See [the wizard guide](./docs/wizard.md). + +## Configuration editors + +`editConfig` opens a JSON configuration file as a list of settings, edits each one with the prompt for its field, and writes the file only when the person saves. Dotted names edit nested values, and keys without a field are preserved. + +```ts +import {editConfig} from '@relay-cli/forms'; + +const {saved, changes} = await editConfig('./service.json', [ + {kind: 'text', name: 'name', label: 'Service name', required: true}, + {kind: 'number', name: 'server.port', label: 'Port', initial: 3000, min: 1, max: 65535}, + {kind: 'text', name: 'token', label: 'API token', mask: true}, +]); +``` + +Run `npm run demo -- config` to try it on a sample document. See [the configuration editor guide](./docs/config-editor.md). + ## Build your own form `runForm` renders any Ink component where the cursor is and resolves with the value the component submits. Ctrl+C cancels it, and `runForm` then rejects with `FormCancelledError`. `Prompt`, `PromptList`, and `FormFrame` are ordinary components, so they can be combined with your own. diff --git a/SECURITY.md b/SECURITY.md index 333097e..2aa1c95 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,4 +6,4 @@ Please report a vulnerability privately through GitHub's security advisory form ## Secrets and configuration -Forms often collect tokens and write configuration files. Masked input hides a value on screen but does not protect it in memory or on disk; store secrets with the platform's credential store when one is available, and keep configuration files that contain them readable only by their owner. +Forms often collect tokens and write configuration files. Masked input hides a value on screen but does not protect it in memory or on disk; store secrets with the platform's credential store when one is available, and keep configuration files that contain them readable only by their owner, for example with `editConfig(path, fields, {mode: 0o600})`. diff --git a/docs/architecture.md b/docs/architecture.md index 14809f6..8f7e79b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,5 +5,7 @@ Relay Forms renders setup flows inline, below the command that started them, ins 1. `runForm` renders a form at the cursor and resolves with the value it submits. Ctrl+C and SIGTERM cancel the form, and `runForm` rejects with `FormCancelledError`. 2. `FormFrame` draws the title, a ruled body, and a footer for key hints. 3. A field describes one question. `Prompt` reads keys for it, validates the answer, and then collapses to a single answered line. `PromptList` asks fields in order. `ask` and `askAll` wrap both in `runForm`. See [prompts](./prompts.md). +4. `Wizard` groups fields into steps, decides which steps apply from the answers so far, and ends with a review screen. `runWizard` wraps it in `runForm`. See [wizards](./wizard.md). +5. `ConfigEditor` lists settings described by fields over a configuration document, edits one at a time with `Prompt`, and reports the changes on save. `editConfig` loads and saves a JSON file around it. See [the configuration editor](./config-editor.md). The editing rules for text and lists are plain functions (`editText`, and the helpers in `src/prompts/choices.ts`), so they are tested without rendering. Each component is a plain Ink component, so a form can also be rendered inside a larger Ink application. diff --git a/docs/config-editor.md b/docs/config-editor.md new file mode 100644 index 0000000..61f1a73 --- /dev/null +++ b/docs/config-editor.md @@ -0,0 +1,47 @@ +# Configuration editor + +`editConfig` opens a JSON configuration file as a list of settings. Each setting is described by a field, edited with the same prompt a setup flow would use, and validated the same way. Nothing is written until the person saves. + +```ts +import {editConfig, type Field} from '@relay-cli/forms'; + +const fields: Field[] = [ + {kind: 'text', name: 'name', label: 'Service name', required: true}, + {kind: 'number', name: 'server.port', label: 'Port', initial: 3000, min: 1, max: 65535}, + { + kind: 'select', + name: 'log.level', + label: 'Log level', + options: [{value: 'info'}, {value: 'debug'}], + }, + {kind: 'text', name: 'token', label: 'API token', mask: true}, +]; + +const result = await editConfig('./service.json', fields, {mode: 0o600}); +if (result.saved) console.log(`Saved ${result.changes.length} changes.`); +``` + +## Values + +- A dotted field name such as `server.port` reads and writes a nested value. Saving creates the objects on the way. +- A setting that is not in the file shows its field's `initial` as `(default)`, or `not set`. Defaults are not written to the file unless the person edits them. +- A stored value that does not fit its field, such as a string where a number belongs or an option that no longer exists, is shown as `(invalid)` with the raw value, and editing it starts from the field's default. +- Keys that no field describes are kept as they are. +- Masked settings never show their value. + +## Keys + +| Key | Action | +| ------------------- | -------------------------------------------------------------- | +| ↑↓, `j`/`k` | Move between settings | +| Enter | Edit the setting. Enter saves the value, Esc abandons the edit | +| Delete or Backspace | Remove the setting so it falls back to its default | +| `r` | Revert the setting to the value it was loaded with | +| `s` | Save every change and close | +| `q` or Esc | Close, asking first when there are unsaved changes | + +## Files + +`loadConfig` reads a missing or empty file as `{}` and refuses a file that is not a JSON object rather than overwriting it. `saveConfig` writes two-space JSON with a trailing newline to a temporary file beside the target and renames it into place. Pass `mode: 0o600` when the file holds secrets; otherwise a replaced file keeps its permissions. + +`ConfigEditor` is the component behind `editConfig`. It takes the document and reports `onSave(values, changes)` and `onQuit()`, so it can edit configuration stored anywhere. diff --git a/docs/prompts.md b/docs/prompts.md index b1c8f6d..62c4331 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -12,7 +12,7 @@ A prompt is one question described by a field. `ask` asks one field, `askAll` as | `select` | the value | ↑↓ or `j`/`k`, Home/End, Enter | `options`, `initial`, `limit` | | `multiselect` | the values | ↑↓, Space toggles, `a` toggles all, Enter | `options`, `initial`, `min`, `max`, `limit` | -Every field has a `name`, a `label`, and an optional `hint` shown beneath the active question. Esc cancels, and `ask` and `askAll` then reject with `FormCancelledError`. +Every field has a `name`, a `label`, and an optional `hint` shown beneath the active question. In `askAll` and `PromptList`, Esc returns to the previous question with its answer kept. At the first question Esc cancels, and `ask` and `askAll` then reject with `FormCancelledError`. Options of `select` and `multiselect` take a `value`, an optional `label` and `hint`, and `disabled`, which may be a string explaining why the option cannot be picked. Lists longer than `limit` (7 by default) scroll. diff --git a/docs/wizard.md b/docs/wizard.md new file mode 100644 index 0000000..d67b13e --- /dev/null +++ b/docs/wizard.md @@ -0,0 +1,62 @@ +# Wizards + +A wizard splits a setup flow into steps. Each step asks its fields with `PromptList`, a progress row shows where the person is, and a review screen lists every answer before the wizard submits. + +```ts +import {runWizard, type WizardStep} from '@relay-cli/forms'; + +const steps: WizardStep[] = [ + { + id: 'project', + title: 'Project', + fields: [ + {kind: 'text', name: 'name', label: 'Project name', required: true}, + { + kind: 'select', + name: 'kind', + label: 'Kind', + options: [{value: 'library'}, {value: 'service'}], + }, + ], + }, + { + id: 'service', + title: 'Service', + when: (answers) => answers.kind === 'service', + fields: [{kind: 'number', name: 'port', label: 'Port', initial: 3000}], + }, + { + id: 'publish', + title: 'Publish', + fields: (answers) => [ + {kind: 'confirm', name: 'git', label: `Initialize git for ${String(answers.name)}?`}, + ], + }, +]; + +const answers = await runWizard<{name: string; kind: string; port?: number; git: boolean}>(steps, { + title: 'Create a project', +}); +``` + +## Steps + +| Property | Meaning | +| ------------- | ------------------------------------------------------------- | +| `id` | A stable identifier, unique within the wizard. | +| `title` | A short name for the progress row and the review screen. | +| `description` | One line shown above the step's questions. | +| `fields` | The step's fields, or a function of the answers given so far. | +| `when` | Skip the step when this returns false. | + +A step skipped by `when` does not appear in the progress row, and its answers are left out of the result even if they were given before an earlier answer changed. + +## Navigation + +- Enter accepts the current answer and moves on. +- Esc returns to the previous question. At the first question of a step it returns to the last question of the previous step, with its answers kept. At the very first question it cancels, and `runWizard` rejects with `FormCancelledError`. +- The review screen offers `Yes, continue` and a `Change …` entry for each step. Changing a step asks it again from its first question, with the earlier answers filled in. If the new answers make another step apply, that step is asked before the review screen returns. + +Pass `review: false` to submit as soon as the last step is answered, and `initialValues` to start from existing answers, such as a configuration file. + +`Wizard` is the component behind `runWizard`, for use inside a larger Ink application. diff --git a/src/cli.tsx b/src/cli.tsx index fd5b3d6..d711a87 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -2,7 +2,7 @@ import {renderToString} from 'ink'; import {helpText, parseCliOptions} from './cli/options.js'; -import {DemoForm} from './demo/index.js'; +import {demos} from './demo/index.js'; import {FormCancelledError, runForm} from './run.js'; import {relayFormsVersion} from './version.js'; @@ -19,20 +19,21 @@ const main = async (): Promise => { } if (options.help) return void process.stdout.write(helpText); if (options.version) return void process.stdout.write(`${relayFormsVersion}\n`); - if (!options.demo) { + if (options.demo === undefined) { process.stderr.write(`Choose --demo to run the demonstration.\n\n${helpText}`); process.exitCode = 2; return; } + const Demo = demos[options.demo]; if (options.snapshot || !process.stdout.isTTY || !process.stdin.isTTY) { const columns = process.stdout.columns || 80; - process.stdout.write(`${renderToString(, {columns})}\n`); + process.stdout.write(`${renderToString(, {columns})}\n`); return; } try { const answers = await runForm>(({submit, cancel}) => ( - + )); process.stdout.write(`\n${JSON.stringify(answers, undefined, 2)}\n`); } catch (error) { diff --git a/src/cli/options.ts b/src/cli/options.ts index 3eb6526..1ad2a2f 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -1,33 +1,52 @@ +export const demoNames = ['prompts', 'wizard', 'config'] as const; +export type DemoName = (typeof demoNames)[number]; + export interface CliOptions { help: boolean; version: boolean; - demo: boolean; + /** The demonstration to run, or `undefined` when none was chosen. */ + demo: DemoName | undefined; snapshot: boolean; } export const helpText = `Relay Forms Usage: - relay-forms --demo - relay-forms --demo --snapshot + relay-forms --demo [prompts|wizard|config] + relay-forms --demo wizard --snapshot Options: - --demo Run the built-in setup demonstration + --demo [name] Run a built-in demonstration (default: prompts) --snapshot Print the demonstration's first screen and exit -h, --help Show this help -v, --version Show the version +Demonstrations: + prompts A project setup flow that uses every kind of question + wizard The same flow split into steps, with a review screen + config A settings editor over a sample configuration file + Relay Forms is a library. Import it from @relay-cli/forms to build your own setup flow. `; +const isDemoName = (value: string | undefined): value is DemoName => + demoNames.includes(value as DemoName); + export const parseCliOptions = (arguments_: readonly string[]): CliOptions => { - const options: CliOptions = {help: false, version: false, demo: false, snapshot: false}; - for (const argument of arguments_) { + const options: CliOptions = {help: false, version: false, demo: undefined, snapshot: false}; + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index] ?? ''; if (argument === '-h' || argument === '--help') options.help = true; else if (argument === '-v' || argument === '--version') options.version = true; - else if (argument === '--demo') options.demo = true; else if (argument === '--snapshot') options.snapshot = true; - else throw new Error(`Unknown option: ${argument}`); + else if (argument === '--demo' || argument.startsWith('--demo=')) { + const inline = argument.startsWith('--demo=') ? argument.slice('--demo='.length) : undefined; + const next = arguments_[index + 1]; + const name = inline ?? (isDemoName(next) ? next : 'prompts'); + if (!isDemoName(name)) throw new Error(`Unknown demonstration: ${name}`); + if (inline === undefined && name === next) index += 1; + options.demo = name; + } else throw new Error(`Unknown option: ${argument}`); } return options; }; diff --git a/src/config/ConfigEditor.tsx b/src/config/ConfigEditor.tsx new file mode 100644 index 0000000..579f870 --- /dev/null +++ b/src/config/ConfigEditor.tsx @@ -0,0 +1,207 @@ +import {useState} from 'react'; +import {Box, Text, useInput} from 'ink'; +import {FormFrame} from '../frame/index.js'; +import {formatAnswer, Prompt, type Field} from '../prompts/index.js'; +import {relayTheme, type FormTheme} from '../theme.js'; +import { + diffValues, + fitsField, + getValue, + setValue, + unsetValue, + type ConfigChange, + type ConfigValues, +} from './values.js'; + +export interface ConfigEditorProps { + title: string; + /** Shown beside the title, usually the file path. */ + subtitle?: string; + /** One field per editable setting. A dotted name such as `server.port` edits a nested value. */ + fields: readonly Field[]; + /** The document as it was loaded. Keys without a field are kept as they are. */ + values: ConfigValues; + onSave: (values: ConfigValues, changes: ConfigChange[]) => void; + /** Called when the person leaves without saving, after confirming any discarded changes. */ + onQuit: () => void; + isActive?: boolean; + theme?: FormTheme; +} + +type Mode = + {name: 'list'} | {name: 'edit'} | {name: 'quit'} | {name: 'saved'; changes: ConfigChange[]}; + +const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}`; + +const ValueText = ({field, value, theme}: {field: Field; value: unknown; theme: FormTheme}) => { + if (value === undefined) { + return field.initial === undefined ? ( + not set + ) : ( + {formatAnswer(field, field.initial)} (default) + ); + } + if (!fitsField(field, value)) { + return {JSON.stringify(value)} (invalid); + } + return {formatAnswer(field, value)}; +}; + +const describe = (field: Field, value: unknown): string => + value === undefined + ? 'default' + : fitsField(field, value) + ? formatAnswer(field, value) + : JSON.stringify(value); + +/** + * A list of settings that can be edited one at a time and saved together. Each + * setting is edited with the prompt for its field, so it is validated the same + * way as in a setup flow. + */ +export const ConfigEditor = ({ + title, + subtitle, + fields, + values: original, + onSave, + onQuit, + isActive = true, + theme = relayTheme, +}: ConfigEditorProps) => { + const [values, setValues] = useState(original); + const [index, setIndex] = useState(0); + const [mode, setMode] = useState({name: 'list'}); + const changes = diffValues(fields, original, values); + const selected = fields[index]; + const labelWidth = Math.max(0, ...fields.map((field) => field.label.length)); + + const save = (): void => { + setMode({name: 'saved', changes}); + onSave(values, changes); + }; + + useInput( + (input, key) => { + if (key.upArrow || input === 'k') + return setIndex((index - 1 + fields.length) % fields.length); + if (key.downArrow || input === 'j') return setIndex((index + 1) % fields.length); + if (key.home) return setIndex(0); + if (key.end) return setIndex(fields.length - 1); + if (key.return && selected !== undefined) return setMode({name: 'edit'}); + if ((key.delete || key.backspace) && selected !== undefined) { + return setValues(unsetValue(values, selected.name)); + } + if (input === 'r' && selected !== undefined) { + const before = getValue(original, selected.name); + return setValues( + before === undefined + ? unsetValue(values, selected.name) + : setValue(values, selected.name, before), + ); + } + if (input === 's') return save(); + if (input === 'q' || key.escape) { + if (changes.length === 0) return onQuit(); + setMode({name: 'quit'}); + } + }, + {isActive: isActive && mode.name === 'list'}, + ); + + if (mode.name === 'saved') { + return ( + + {mode.changes.length === 0 ? ( + No changes. + ) : ( + mode.changes.map((change) => { + const field = fields.find((candidate) => candidate.name === change.name); + return field === undefined ? null : ( + + ✔ + {field.label} + + {' '} + · {describe(field, change.before)} → {describe(field, change.after)} + + + ); + }) + )} + + ); + } + + const footer = + mode.name === 'edit' ? ( + enter save value · esc stop editing + ) : mode.name === 'quit' ? undefined : ( + + + {selected?.hint ?? selected?.name ?? ''} + + + ↑↓ move · enter edit · del default · r revert · s save · q quit + + + ); + + return ( + 0 ? plural(changes.length, 'unsaved change') : (subtitle ?? 'saved') + } + theme={theme} + footer={footer} + > + {fields.map((field, row) => { + const value = getValue(values, field.name); + const highlighted = row === index; + if (highlighted && mode.name === 'edit') { + return ( + { + setValues(setValue(values, field.name, next)); + setMode({name: 'list'}); + }} + onCancel={() => setMode({name: 'list'})} + isActive={isActive} + theme={theme} + /> + ); + } + const changed = changes.some((change) => change.name === field.name); + return ( + + {highlighted && mode.name === 'list' ? '›' : ' '} + {changed ? '●' : ' '} + + {' '} + {field.label.padEnd(labelWidth)} + + + + + ); + })} + {mode.name === 'quit' ? ( + (discard ? onQuit() : setMode({name: 'list'}))} + onCancel={() => setMode({name: 'list'})} + isActive={isActive} + theme={theme} + /> + ) : null} + + ); +}; diff --git a/src/config/edit.tsx b/src/config/edit.tsx new file mode 100644 index 0000000..b7bccba --- /dev/null +++ b/src/config/edit.tsx @@ -0,0 +1,51 @@ +import type {Field} from '../prompts/index.js'; +import {runForm, type RunFormOptions} from '../run.js'; +import type {FormTheme} from '../theme.js'; +import {ConfigEditor} from './ConfigEditor.js'; +import {loadConfig, saveConfig, type SaveConfigOptions} from './file.js'; +import type {ConfigChange, ConfigValues} from './values.js'; + +export interface EditConfigOptions extends RunFormOptions, SaveConfigOptions { + /** Defaults to "Settings". */ + title?: string; + theme?: FormTheme; +} + +export interface EditConfigResult { + /** Whether the person saved. When false the file was not touched. */ + saved: boolean; + /** The document as it is now on disk. */ + values: ConfigValues; + changes: ConfigChange[]; +} + +/** + * Open a JSON configuration file in the editor and write it back when the + * person saves. Keys the fields do not describe are preserved. Rejects with + * `FormCancelledError` on Ctrl+C, leaving the file unchanged. + */ +export const editConfig = async ( + path: string, + fields: readonly Field[], + options: EditConfigOptions = {}, +): Promise => { + const original = await loadConfig(path); + const result = await runForm( + ({submit}) => ( + submit({saved: true, values, changes})} + onQuit={() => submit({saved: false, values: original, changes: []})} + {...(options.theme === undefined ? {} : {theme: options.theme})} + /> + ), + options, + ); + if (result.saved && result.changes.length > 0) { + await saveConfig(path, result.values, options.mode === undefined ? {} : {mode: options.mode}); + } + return result; +}; diff --git a/src/config/file.ts b/src/config/file.ts new file mode 100644 index 0000000..ee2f6ec --- /dev/null +++ b/src/config/file.ts @@ -0,0 +1,72 @@ +import {randomBytes} from 'node:crypto'; +import {mkdir, readFile, rename, rm, stat, writeFile} from 'node:fs/promises'; +import {dirname, join, basename} from 'node:path'; +import type {ConfigValues} from './values.js'; + +export interface SaveConfigOptions { + /** + * File permissions for the written file. Use `0o600` when the file holds + * secrets. Defaults to the permissions of the file being replaced, or the + * process umask for a new file. + */ + mode?: number; +} + +/** + * Read a JSON configuration file. A missing file reads as an empty document; a + * file that is not a JSON object is an error rather than being overwritten. + */ +export const loadConfig = async (path: string): Promise => { + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; + throw error; + } + if (text.trim() === '') return {}; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error(`${path} is not valid JSON: ${(error as Error).message}`, {cause: error}); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`${path} must contain a JSON object.`); + } + return parsed as ConfigValues; +}; + +const existingMode = async (path: string): Promise => { + try { + return (await stat(path)).mode & 0o777; + } catch { + return undefined; + } +}; + +/** + * Write a configuration file as two-space JSON with a trailing newline. The + * file is written beside the target and renamed into place, so a crash never + * leaves half a file. + */ +export const saveConfig = async ( + path: string, + values: ConfigValues, + options: SaveConfigOptions = {}, +): Promise => { + const directory = dirname(path); + await mkdir(directory, {recursive: true}); + const mode = options.mode ?? (await existingMode(path)); + const temporary = join(directory, `.${basename(path)}.${randomBytes(6).toString('hex')}.tmp`); + try { + await writeFile(temporary, `${JSON.stringify(values, undefined, 2)}\n`, { + encoding: 'utf8', + ...(mode === undefined ? {} : {mode}), + }); + await rename(temporary, path); + } catch (error) { + await rm(temporary, {force: true}); + throw error; + } +}; diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..24a255b --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,11 @@ +export {ConfigEditor, type ConfigEditorProps} from './ConfigEditor.js'; +export {editConfig, type EditConfigOptions, type EditConfigResult} from './edit.js'; +export {loadConfig, saveConfig, type SaveConfigOptions} from './file.js'; +export { + diffValues, + getValue, + setValue, + unsetValue, + type ConfigChange, + type ConfigValues, +} from './values.js'; diff --git a/src/config/values.ts b/src/config/values.ts new file mode 100644 index 0000000..f8a8f75 --- /dev/null +++ b/src/config/values.ts @@ -0,0 +1,93 @@ +import type {Field} from '../prompts/index.js'; + +/** A configuration document: nested objects of JSON values. */ +export type ConfigValues = Record; + +/** One field whose value differs from the value it started with. */ +export interface ConfigChange { + name: string; + before: unknown; + after: unknown; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const forbidden = new Set(['__proto__', 'prototype', 'constructor']); + +const segments = (name: string): string[] => { + const parts = name.split('.'); + if (parts.some((part) => part === '' || forbidden.has(part))) { + throw new Error(`Invalid configuration key: ${name}`); + } + return parts; +}; + +/** Read a value by a dotted name such as `server.port`. */ +export const getValue = (values: ConfigValues, name: string): unknown => { + let current: unknown = values; + for (const part of segments(name)) { + if (!isRecord(current) || !Object.hasOwn(current, part)) return undefined; + current = current[part]; + } + return current; +}; + +/** Return a copy with a value set by dotted name, creating objects on the way. */ +export const setValue = (values: ConfigValues, name: string, value: unknown): ConfigValues => { + const [head, ...rest] = segments(name); + if (head === undefined) return values; + if (rest.length === 0) return {...values, [head]: value}; + const child = values[head]; + return {...values, [head]: setValue(isRecord(child) ? child : {}, rest.join('.'), value)}; +}; + +const without = (values: ConfigValues, key: string): ConfigValues => + Object.fromEntries(Object.entries(values).filter(([candidate]) => candidate !== key)); + +/** Return a copy without a value, removing objects that become empty. */ +export const unsetValue = (values: ConfigValues, name: string): ConfigValues => { + const [head, ...rest] = segments(name); + if (head === undefined || !Object.hasOwn(values, head)) return values; + if (rest.length === 0) return without(values, head); + const child = values[head]; + if (!isRecord(child)) return values; + const next = unsetValue(child, rest.join('.')); + return Object.keys(next).length === 0 ? without(values, head) : {...values, [head]: next}; +}; + +const same = (left: unknown, right: unknown): boolean => + JSON.stringify(left) === JSON.stringify(right); + +/** The fields whose values differ between two documents, in field order. */ +export const diffValues = ( + fields: readonly Field[], + before: ConfigValues, + after: ConfigValues, +): ConfigChange[] => + fields + .map((field) => ({ + name: field.name, + before: getValue(before, field.name), + after: getValue(after, field.name), + })) + .filter((change) => !same(change.before, change.after)); + +/** Whether a stored value can be shown and edited by a field of this kind. */ +export const fitsField = (field: Field, value: unknown): boolean => { + switch (field.kind) { + case 'text': + return typeof value === 'string'; + case 'number': + return typeof value === 'number' && Number.isFinite(value); + case 'confirm': + return typeof value === 'boolean'; + case 'select': + return field.options.some((option) => option.value === value); + case 'multiselect': + return ( + Array.isArray(value) && + value.every((item) => field.options.some((option) => option.value === item)) + ); + } +}; diff --git a/src/demo/DemoConfig.tsx b/src/demo/DemoConfig.tsx new file mode 100644 index 0000000..30068a8 --- /dev/null +++ b/src/demo/DemoConfig.tsx @@ -0,0 +1,43 @@ +import {ConfigEditor} from '../config/index.js'; +import type {Field} from '../prompts/index.js'; +import type {DemoProps} from './types.js'; + +export const demoConfigFields: Field[] = [ + {kind: 'text', name: 'name', label: 'Service name', required: true}, + {kind: 'number', name: 'server.port', label: 'Port', initial: 3000, min: 1, max: 65535}, + {kind: 'confirm', name: 'server.tls', label: 'Serve over TLS', initial: false}, + { + kind: 'select', + name: 'log.level', + label: 'Log level', + options: [{value: 'error'}, {value: 'warn'}, {value: 'info'}, {value: 'debug'}], + initial: 'info', + }, + { + kind: 'multiselect', + name: 'features', + label: 'Features', + options: [{value: 'metrics'}, {value: 'tracing'}, {value: 'health'}], + }, + {kind: 'text', name: 'token', label: 'API token', mask: true, hint: 'Stored in the file.'}, +]; + +export const demoConfigValues = { + name: 'relay-api', + server: {port: 8080}, + log: {level: 'verbose'}, + features: ['health'], +}; + +/** A configuration editor over an in-memory document; nothing is written to disk. */ +export const DemoConfig = ({interactive = true, onSubmit}: DemoProps) => ( + onSubmit?.(values)} + onQuit={() => onSubmit?.(demoConfigValues)} + /> +); diff --git a/src/demo/DemoForm.tsx b/src/demo/DemoForm.tsx index 69ebc7b..f1546ab 100644 --- a/src/demo/DemoForm.tsx +++ b/src/demo/DemoForm.tsx @@ -3,6 +3,7 @@ import {Text} from 'ink'; import {FormFrame} from '../frame/index.js'; import {defineFields, keyHints, PromptList, type Field} from '../prompts/index.js'; import {relayTheme} from '../theme.js'; +import type {DemoProps} from './types.js'; export const demoFields = defineFields([ { @@ -40,15 +41,8 @@ export const demoFields = defineFields([ {kind: 'confirm', name: 'git', label: 'Initialize a git repository?', initial: true}, ]); -export interface DemoFormProps { - /** Read keys. A snapshot renders the first question without waiting. */ - interactive?: boolean; - onSubmit?: (answers: Record) => void; - onCancel?: () => void; -} - /** A project setup flow that shows each kind of prompt. */ -export const DemoForm = ({interactive = true, onSubmit, onCancel}: DemoFormProps) => { +export const DemoForm = ({interactive = true, onSubmit, onCancel}: DemoProps) => { const [current, setCurrent] = useState(demoFields[0]); return ( { + const found = demoFields.find((candidate) => candidate.name === name); + if (found === undefined) throw new Error(`No demonstration field named ${name}`); + return found; +}; + +export const demoSteps: WizardStep[] = [ + { + id: 'project', + title: 'Project', + fields: [ + field('name'), + { + kind: 'select', + name: 'kind', + label: 'What are you building?', + options: [ + {value: 'library', label: 'A library'}, + {value: 'service', label: 'A web service'}, + ], + }, + ], + }, + { + id: 'tooling', + title: 'Tooling', + fields: [field('packageManager'), field('features')], + }, + { + id: 'service', + title: 'Service', + description: 'Only asked for web services.', + when: (answers) => answers.kind === 'service', + fields: [field('port')], + }, + { + id: 'publish', + title: 'Publish', + fields: (answers) => [ + field('token'), + { + kind: 'confirm', + name: 'git', + label: `Initialize a git repository for ${String(answers.name)}?`, + initial: true, + }, + ], + }, +]; + +/** The project setup flow split into steps, with a review screen. */ +export const DemoWizard = ({interactive = true, onSubmit, onCancel}: DemoProps) => ( + undefined)} + {...(onCancel === undefined ? {} : {onCancel})} + /> +); diff --git a/src/demo/index.ts b/src/demo/index.ts index 8f89e96..2888e83 100644 --- a/src/demo/index.ts +++ b/src/demo/index.ts @@ -1 +1,17 @@ -export {DemoForm, demoFields, type DemoFormProps} from './DemoForm.js'; +import type {ReactNode} from 'react'; +import type {DemoName} from '../cli/options.js'; +import {DemoConfig} from './DemoConfig.js'; +import {DemoForm} from './DemoForm.js'; +import {DemoWizard} from './DemoWizard.js'; +import type {DemoProps} from './types.js'; + +export {DemoConfig, demoConfigFields, demoConfigValues} from './DemoConfig.js'; +export {DemoForm, demoFields} from './DemoForm.js'; +export {DemoWizard, demoSteps} from './DemoWizard.js'; +export type {DemoProps} from './types.js'; + +export const demos: Record ReactNode> = { + prompts: DemoForm, + wizard: DemoWizard, + config: DemoConfig, +}; diff --git a/src/demo/types.ts b/src/demo/types.ts new file mode 100644 index 0000000..e6c63df --- /dev/null +++ b/src/demo/types.ts @@ -0,0 +1,6 @@ +export interface DemoProps { + /** Read keys. A snapshot renders the first screen without waiting. */ + interactive?: boolean; + onSubmit?: (answers: Record) => void; + onCancel?: () => void; +} diff --git a/src/index.ts b/src/index.ts index 7385687..e61143a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ export * from './frame/index.js'; +export * from './config/index.js'; export * from './prompts/index.js'; export {FormCancelledError, runForm, type FormControls, type RunFormOptions} from './run.js'; export {formThemes, monoTheme, relayTheme, type FormTheme} from './theme.js'; export {relayFormsVersion} from './version.js'; +export * from './wizard/index.js'; diff --git a/src/prompts/Prompt.tsx b/src/prompts/Prompt.tsx index 1be8a3e..01555f7 100644 --- a/src/prompts/Prompt.tsx +++ b/src/prompts/Prompt.tsx @@ -1,4 +1,4 @@ -import {useEffect, useRef, useState} from 'react'; +import {useRef, useState} from 'react'; import {Box, Text, useInput} from 'ink'; import {relayTheme, type FormTheme} from '../theme.js'; import {formatAnswer, validateAnswer} from './format.js'; @@ -76,16 +76,13 @@ export const Prompt = ({ {isActive: isActive && phase.name !== 'done'}, ); - const submitted = useRef(false); - useEffect(() => { - if (phase.name !== 'done' || submitted.current) return; - submitted.current = true; - onSubmit(phase.value as FieldValue); - }, [phase, onSubmit]); - + // Submit in the same update that collapses the prompt, so a list can mount + // the next question before the next key arrives. const settle = (value: unknown, message: string | undefined): void => { setError(message); - setPhase(message === undefined ? {name: 'done', value} : {name: 'editing'}); + if (message !== undefined) return setPhase({name: 'editing'}); + setPhase({name: 'done', value}); + onSubmit(value as FieldValue); }; const accept = (value: unknown): void => { diff --git a/src/prompts/ask.tsx b/src/prompts/ask.tsx index 9183494..c8f59f2 100644 --- a/src/prompts/ask.tsx +++ b/src/prompts/ask.tsx @@ -1,4 +1,4 @@ -import {useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; import {Box} from 'ink'; import {runForm, type RunFormOptions} from '../run.js'; import {relayTheme, type FormTheme} from '../theme.js'; @@ -34,44 +34,71 @@ export interface PromptListProps { fields: readonly Field[]; /** Answers to start each field from, keyed by field name. */ initialValues?: Readonly>; + /** + * Start at the last field, showing the earlier ones as answered from + * `initialValues`, such as when returning to a step of a wizard. + */ + resume?: boolean; onSubmit: (answers: Record) => void; /** Called after each answer with the field asked next, if any. */ onAnswer?: (field: Field, value: unknown, next: Field | undefined) => void; + /** Called on Esc at the first field. Esc at a later field returns to the one before. */ onCancel?: () => void; isActive?: boolean; theme?: FormTheme; } +const startIndex = ( + fields: readonly Field[], + values: Readonly>, + resume: boolean, +): number => { + if (!resume) return 0; + const missing = fields.findIndex((field) => !(field.name in values)); + return missing === -1 ? Math.max(0, fields.length - 1) : missing; +}; + /** Fields asked one after another, with earlier answers kept above the current one. */ export const PromptList = ({ fields, initialValues = {}, + resume = false, onSubmit, onAnswer, onCancel, isActive = true, theme = relayTheme, }: PromptListProps) => { - const [answers, setAnswers] = useState>({}); - const index = Object.keys(answers).length; + const [values, setValues] = useState>(() => ({...initialValues})); + const [index, setIndex] = useState(() => startIndex(fields, initialValues, resume)); const current = fields[index]; + + const submitted = useRef(false); + useEffect(() => { + if (fields.length > 0 || submitted.current) return; + submitted.current = true; + onSubmit({}); + }, [fields, onSubmit]); + return ( {fields.slice(0, index).map((field) => ( - + ))} {current === undefined ? null : ( { - const next = {...answers, [current.name]: value}; - setAnswers(next); + const next = {...values, [current.name]: value}; + setValues(next); onAnswer?.(current, value, fields[index + 1]); - if (index + 1 === fields.length) onSubmit(next); + if (index + 1 < fields.length) return setIndex(index + 1); + setIndex(fields.length); + onSubmit(Object.fromEntries(fields.map((field) => [field.name, next[field.name]]))); }} - {...(onCancel === undefined ? {} : {onCancel})} + onCancel={() => (index > 0 ? setIndex(index - 1) : onCancel?.())} isActive={isActive} theme={theme} /> diff --git a/src/run.tsx b/src/run.tsx index 80be57b..d9198ed 100644 --- a/src/run.tsx +++ b/src/run.tsx @@ -39,20 +39,24 @@ export const runForm = async ( build: (controls: FormControls) => ReactNode, options: RunFormOptions = {}, ): Promise => { - // A form may finish during its first render, before `render` has returned, - // so the outcome lives on an object the controls and the caller share. + // Unmount on the next turn of the event loop: a form may finish during its + // first render, before `render` has returned, and the update that shows the + // final answer should be drawn before the form closes. const state: {settled: boolean; result?: {value: T}; app?: Instance} = {settled: false}; + const close = (): void => { + setTimeout(() => state.app?.unmount(), 0); + }; const controls: FormControls = { submit: (value) => { if (state.settled) return; state.settled = true; state.result = {value}; - state.app?.unmount(); + close(); }, cancel: () => { if (state.settled) return; state.settled = true; - state.app?.unmount(); + close(); }, }; const app = render( @@ -65,7 +69,6 @@ export const runForm = async ( }, ); state.app = app; - if (state.settled) app.unmount(); const interrupt = (): void => controls.cancel(); process.once('SIGTERM', interrupt); try { diff --git a/src/wizard/Wizard.tsx b/src/wizard/Wizard.tsx new file mode 100644 index 0000000..6daa37f --- /dev/null +++ b/src/wizard/Wizard.tsx @@ -0,0 +1,260 @@ +import {useEffect, useRef, useState} from 'react'; +import {Box, Text} from 'ink'; +import {FormFrame} from '../frame/index.js'; +import {AnsweredLine, Prompt, PromptList, type Field} from '../prompts/index.js'; +import {relayTheme, type FormTheme} from '../theme.js'; + +export type WizardAnswers = Record; + +export interface WizardStep { + /** A stable identifier, unique within the wizard. */ + id: string; + /** A short name shown in the progress row and on the review screen. */ + title: string; + /** One line shown above the step's questions. */ + description?: string; + /** The step's questions, or a function of the answers given so far. */ + fields: readonly Field[] | ((answers: WizardAnswers) => readonly Field[]); + /** Skip the step when this returns false. Answers to skipped steps are dropped. */ + when?: (answers: WizardAnswers) => boolean; +} + +export interface WizardProps { + title: string; + steps: readonly WizardStep[]; + /** Answers to start from, such as an existing configuration. */ + initialValues?: WizardAnswers; + /** Show every answer and ask for confirmation before submitting. Defaults to true. */ + review?: boolean; + onSubmit: (answers: WizardAnswers) => void; + /** Called on Esc at the first question of the first step. */ + onCancel?: () => void; + isActive?: boolean; + theme?: FormTheme; +} + +const REVIEW = Symbol('review'); +const DONE = Symbol('done'); +type Position = {step: string; resume: boolean} | typeof REVIEW | typeof DONE; + +export const stepFields = (step: WizardStep, answers: WizardAnswers): readonly Field[] => + typeof step.fields === 'function' ? step.fields(answers) : step.fields; + +/** The steps that apply to a set of answers, in order. */ +export const visibleSteps = (steps: readonly WizardStep[], answers: WizardAnswers): WizardStep[] => + steps.filter((step) => step.when?.(answers) ?? true); + +/** Keep only the answers to fields of steps that still apply. */ +export const collectAnswers = ( + steps: readonly WizardStep[], + answers: WizardAnswers, +): WizardAnswers => { + const result: WizardAnswers = {}; + for (const step of visibleSteps(steps, answers)) { + for (const field of stepFields(step, answers)) { + if (field.name in answers) result[field.name] = answers[field.name]; + } + } + return result; +}; + +const Progress = ({ + steps, + completed, + current, + review, + theme, +}: { + steps: readonly WizardStep[]; + completed: ReadonlySet; + current: string | undefined; + review: boolean; + theme: FormTheme; +}) => { + const items = steps.map((step) => ({ + key: step.id, + title: step.title, + state: step.id === current ? 'current' : completed.has(step.id) ? 'done' : 'todo', + })); + if (review) + items.push({key: '', title: 'Review', state: current === undefined ? 'current' : 'todo'}); + return ( + + {items.map((item, index) => ( + + {index === 0 ? '' : · } + {item.state === 'done' ? ( + ✔ {item.title} + ) : item.state === 'current' ? ( + + › {item.title} + + ) : ( + {item.title} + )} + + ))} + + ); +}; + +const Summary = ({ + steps, + answers, + theme, +}: { + steps: readonly WizardStep[]; + answers: WizardAnswers; + theme: FormTheme; +}) => ( + + {visibleSteps(steps, answers).map((step) => ( + + {step.title} + {stepFields(step, answers).map((field) => ( + + ))} + + ))} + +); + +const SUBMIT = '\u0000submit'; + +/** + * A setup flow split into steps. Each step asks its questions with + * `PromptList`; Esc returns to the previous question or step, and the review + * screen lets the person change any step before submitting. + */ +export const Wizard = ({ + title, + steps, + initialValues = {}, + review = true, + onSubmit, + onCancel, + isActive = true, + theme = relayTheme, +}: WizardProps) => { + const [answers, setAnswers] = useState(initialValues); + const [completed, setCompleted] = useState>(new Set()); + const [position, setPosition] = useState(() => { + const first = visibleSteps(steps, initialValues)[0]; + return first === undefined ? (review ? REVIEW : DONE) : {step: first.id, resume: false}; + }); + + const visible = visibleSteps(steps, answers); + const finish = (nextAnswers: WizardAnswers, nextCompleted: ReadonlySet): void => { + const pending = visibleSteps(steps, nextAnswers).find((step) => !nextCompleted.has(step.id)); + if (pending !== undefined) setPosition({step: pending.id, resume: false}); + else setPosition(review ? REVIEW : DONE); + }; + + const submitted = useRef(false); + useEffect(() => { + if (position !== DONE || submitted.current) return; + submitted.current = true; + onSubmit(collectAnswers(steps, answers)); + }, [position, steps, answers, onSubmit]); + + const back = (from: number): void => { + const previous = visible[from - 1]; + if (previous === undefined) return onCancel?.(); + const nextCompleted = new Set(completed); + nextCompleted.delete(previous.id); + setCompleted(nextCompleted); + setPosition({step: previous.id, resume: true}); + }; + + let body; + let current: string | undefined; + let subtitle: string; + if (position === DONE) { + subtitle = 'done'; + body = ; + } else if (position === REVIEW) { + subtitle = 'review'; + body = ( + + + ({value: step.id, label: `Change ${step.title}`})), + ], + }} + onSubmit={(choice) => { + if (choice === SUBMIT) return setPosition(DONE); + const nextCompleted = new Set(completed); + nextCompleted.delete(choice); + setCompleted(nextCompleted); + setPosition({step: choice, resume: false}); + }} + onCancel={() => back(visible.length)} + isActive={isActive} + theme={theme} + /> + + ); + } else { + const index = Math.max( + 0, + visible.findIndex((step) => step.id === position.step), + ); + const step = visible[index]; + current = step?.id; + subtitle = `step ${index + 1} of ${visible.length}`; + body = + step === undefined ? null : ( + + {step.description === undefined ? null : ( + {step.description} + )} + { + const nextAnswers = {...answers, ...stepAnswers}; + const nextCompleted = new Set(completed).add(step.id); + setAnswers(nextAnswers); + setCompleted(nextCompleted); + finish(nextAnswers, nextCompleted); + }} + onCancel={() => back(index)} + isActive={isActive} + theme={theme} + /> + + ); + } + + return ( + enter continue · esc back · ctrl+c cancel + ) + } + > + {position === DONE ? null : ( + + )} + {body} + + ); +}; diff --git a/src/wizard/index.ts b/src/wizard/index.ts new file mode 100644 index 0000000..aced259 --- /dev/null +++ b/src/wizard/index.ts @@ -0,0 +1,10 @@ +export {runWizard, type RunWizardOptions} from './run.js'; +export { + collectAnswers, + stepFields, + visibleSteps, + Wizard, + type WizardAnswers, + type WizardProps, + type WizardStep, +} from './Wizard.js'; diff --git a/src/wizard/run.tsx b/src/wizard/run.tsx new file mode 100644 index 0000000..cdd9328 --- /dev/null +++ b/src/wizard/run.tsx @@ -0,0 +1,34 @@ +import {runForm, type RunFormOptions} from '../run.js'; +import type {FormTheme} from '../theme.js'; +import {Wizard, type WizardAnswers, type WizardStep} from './Wizard.js'; + +export interface RunWizardOptions extends RunFormOptions { + title: string; + initialValues?: WizardAnswers; + review?: boolean; + theme?: FormTheme; +} + +/** + * Run a multi-step setup flow and resolve with the answers to every step that + * applied. Rejects with `FormCancelledError` on Ctrl+C or on Esc at the first + * question. + */ +export const runWizard = ( + steps: readonly WizardStep[], + options: RunWizardOptions, +): Promise => + runForm( + ({submit, cancel}) => ( + submit(answers as T)} + onCancel={cancel} + /> + ), + options, + ); diff --git a/tests/cli-options.test.ts b/tests/cli-options.test.ts index 60f87f8..fb379a7 100644 --- a/tests/cli-options.test.ts +++ b/tests/cli-options.test.ts @@ -2,14 +2,20 @@ import {describe, expect, it} from 'vitest'; import {parseCliOptions} from '../src/cli/options.js'; describe('CLI options', () => { - it('reads the demonstration flags', () => { + it('runs the prompts demonstration by default', () => { expect(parseCliOptions(['--demo', '--snapshot'])).toMatchObject({ - demo: true, + demo: 'prompts', snapshot: true, help: false, }); }); + it('reads a demonstration name', () => { + expect(parseCliOptions(['--demo', 'wizard']).demo).toBe('wizard'); + expect(parseCliOptions(['--demo=wizard']).demo).toBe('wizard'); + expect(() => parseCliOptions(['--demo=nope'])).toThrow('Unknown demonstration'); + }); + it('rejects unknown options', () => { expect(() => parseCliOptions(['--wat'])).toThrow('Unknown option'); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 17f4d8a..f3fb478 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -13,6 +13,18 @@ describe('relay-forms command', () => { expect(stdout).toContain('Relay Forms'); }, 30_000); + it('prints the first step of the wizard demonstration', async () => { + const {stdout} = await run(process.execPath, [tsx, cli, '--demo', 'wizard', '--snapshot']); + expect(stdout).toContain('step 1 of 3'); + expect(stdout).toContain('Project name'); + }, 30_000); + + it('prints the configuration editor demonstration', async () => { + const {stdout} = await run(process.execPath, [tsx, cli, '--demo', 'config', '--snapshot']); + expect(stdout).toContain('Port'); + expect(stdout).toContain('(invalid)'); + }, 30_000); + it('explains how to start when no mode is chosen', async () => { await expect(run(process.execPath, [tsx, cli])).rejects.toMatchObject({code: 2}); }, 30_000); diff --git a/tests/config-editor.test.tsx b/tests/config-editor.test.tsx new file mode 100644 index 0000000..c4f9904 --- /dev/null +++ b/tests/config-editor.test.tsx @@ -0,0 +1,138 @@ +import {mkdtemp, readFile, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import React from 'react'; +import {render} from 'ink-testing-library'; +import {describe, expect, it, vi} from 'vitest'; +import {ConfigEditor, editConfig} from '../src/config/index.js'; +import type {Field} from '../src/prompts/index.js'; +import {fakeTerminal, tick} from './helpers.js'; + +const enter = '\r'; +const escape = '\u001B'; +const down = '\u001B[B'; +const backspace = '\u007F'; + +const fields: Field[] = [ + {kind: 'text', name: 'name', label: 'Name', required: true}, + {kind: 'number', name: 'server.port', label: 'Port', initial: 3000, min: 1, max: 65535}, + {kind: 'select', name: 'log', label: 'Log level', options: [{value: 'info'}, {value: 'debug'}]}, + {kind: 'text', name: 'token', label: 'Token', mask: true}, +]; + +const press = async (stdin: {write: (data: string) => void}, ...keys: string[]) => { + for (const key of keys) { + stdin.write(key); + await tick(); + } +}; + +const editor = (values: Record) => { + const onSave = vi.fn(); + const onQuit = vi.fn(); + const view = render( + , + ); + return {view, onSave, onQuit}; +}; + +describe('ConfigEditor', () => { + it('lists current values, defaults, unset values, and invalid ones', async () => { + const {view} = editor({name: 'api', log: 'verbose', token: 'secret'}); + await tick(); + const frame = view.lastFrame() ?? ''; + expect(frame).toContain('Name api'); + expect(frame).toContain('Port 3000 (default)'); + expect(frame).toContain('Log level "verbose" (invalid)'); + expect(frame).toContain('Token ••••••'); + expect(frame).not.toContain('secret'); + }); + + it('edits a value with its prompt and saves the changes', async () => { + const {view, onSave} = editor({name: 'api', other: 'kept'}); + await tick(); + await press(view.stdin, down, enter, backspace, backspace, backspace, backspace, '8080', enter); + expect(view.lastFrame()).toContain('1 unsaved change'); + expect(view.lastFrame()).toContain('● Port 8080'); + await press(view.stdin, 's'); + expect(onSave).toHaveBeenCalledWith({name: 'api', other: 'kept', server: {port: 8080}}, [ + {name: 'server.port', before: undefined, after: 8080}, + ]); + expect(view.lastFrame()).toContain('Port · default → 8080'); + }); + + it('keeps the value when an edit is abandoned or invalid', async () => { + const {view} = editor({name: 'api'}); + await tick(); + await press(view.stdin, down, enter, '000', enter); + expect(view.lastFrame()).toContain('Enter 65535 or less.'); + await press(view.stdin, escape); + await tick(); + expect(view.lastFrame()).toContain('Port 3000 (default)'); + expect(view.lastFrame()).not.toContain('unsaved'); + }); + + it('reverts a setting and clears one back to its default', async () => { + const {view} = editor({name: 'api', server: {port: 80}}); + await tick(); + await press(view.stdin, down, backspace); + expect(view.lastFrame()).toContain('Port 3000 (default)'); + await press(view.stdin, 'r'); + expect(view.lastFrame()).toContain('Port 80'); + expect(view.lastFrame()).not.toContain('unsaved'); + }); + + it('asks before discarding unsaved changes', async () => { + const {view, onQuit} = editor({name: 'api'}); + await tick(); + await press(view.stdin, down, down, enter, down, enter, 'q'); + expect(view.lastFrame()).toContain('Discard 1 unsaved change?'); + await press(view.stdin, 'n'); + expect(onQuit).not.toHaveBeenCalled(); + await press(view.stdin, 'q', 'y'); + expect(onQuit).toHaveBeenCalled(); + }); + + it('quits at once when nothing changed', async () => { + const {view, onQuit} = editor({name: 'api'}); + await tick(); + await press(view.stdin, 'q'); + expect(onQuit).toHaveBeenCalled(); + }); +}); + +describe('editConfig', () => { + it('writes the file only when the person saves a change', async () => { + const folder = await mkdtemp(join(tmpdir(), 'relay-forms-')); + const path = join(folder, 'config.json'); + await writeFile(path, '{"name": "api", "extra": [1]}'); + + const quit = fakeTerminal(); + const unchanged = editConfig(path, fields, quit); + await tick(); + quit.press('q'); + await expect(unchanged).resolves.toMatchObject({saved: false, changes: []}); + expect(await readFile(path, 'utf8')).toBe('{"name": "api", "extra": [1]}'); + + const terminal = fakeTerminal(); + const pending = editConfig(path, fields, terminal); + await tick(); + for (const key of [down, down, enter, down, enter, 's']) { + terminal.press(key); + await tick(); + } + await expect(pending).resolves.toMatchObject({saved: true}); + expect(JSON.parse(await readFile(path, 'utf8'))).toEqual({ + name: 'api', + extra: [1], + log: 'debug', + }); + expect(terminal.output()).toContain('Log level · default → debug'); + }); +}); diff --git a/tests/config-values.test.ts b/tests/config-values.test.ts new file mode 100644 index 0000000..56f4434 --- /dev/null +++ b/tests/config-values.test.ts @@ -0,0 +1,92 @@ +import {mkdtemp, readdir, readFile, stat, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {describe, expect, it} from 'vitest'; +import { + diffValues, + getValue, + loadConfig, + saveConfig, + setValue, + unsetValue, +} from '../src/config/index.js'; +import type {Field} from '../src/prompts/index.js'; + +describe('dotted values', () => { + const document = {server: {port: 8080, host: 'localhost'}, name: 'api'}; + + it('reads and writes nested values without changing the original', () => { + expect(getValue(document, 'server.port')).toBe(8080); + expect(getValue(document, 'server.missing')).toBeUndefined(); + expect(getValue(document, 'name.length')).toBeUndefined(); + const next = setValue(document, 'server.tls.enabled', true); + expect(next).toEqual({ + server: {port: 8080, host: 'localhost', tls: {enabled: true}}, + name: 'api', + }); + expect(document.server).toEqual({port: 8080, host: 'localhost'}); + }); + + it('removes values and the objects they leave empty', () => { + expect(unsetValue(document, 'server.port')).toEqual({server: {host: 'localhost'}, name: 'api'}); + expect(unsetValue({a: {b: 1}}, 'a.b')).toEqual({}); + expect(unsetValue(document, 'nothing.here')).toBe(document); + }); + + it('refuses keys that would reach the object prototype', () => { + expect(() => setValue({}, '__proto__.polluted', true)).toThrow('Invalid configuration key'); + expect(() => getValue({}, 'a..b')).toThrow('Invalid configuration key'); + }); + + it('lists the fields that changed', () => { + const fields: Field[] = [ + {kind: 'number', name: 'server.port', label: 'Port'}, + {kind: 'text', name: 'name', label: 'Name'}, + ]; + expect(diffValues(fields, document, setValue(document, 'server.port', 9090))).toEqual([ + {name: 'server.port', before: 8080, after: 9090}, + ]); + }); +}); + +describe('configuration files', () => { + const directory = () => mkdtemp(join(tmpdir(), 'relay-forms-')); + + it('reads a missing or empty file as an empty document', async () => { + const folder = await directory(); + await expect(loadConfig(join(folder, 'missing.json'))).resolves.toEqual({}); + await writeFile(join(folder, 'empty.json'), '\n'); + await expect(loadConfig(join(folder, 'empty.json'))).resolves.toEqual({}); + }); + + it('refuses files that are not a JSON object', async () => { + const folder = await directory(); + await writeFile(join(folder, 'broken.json'), '{"a":'); + await expect(loadConfig(join(folder, 'broken.json'))).rejects.toThrow('is not valid JSON'); + await writeFile(join(folder, 'list.json'), '[1, 2]'); + await expect(loadConfig(join(folder, 'list.json'))).rejects.toThrow( + 'must contain a JSON object', + ); + }); + + it('writes formatted JSON in place without leaving temporary files', async () => { + const folder = await directory(); + const path = join(folder, 'nested', 'config.json'); + await saveConfig(path, {server: {port: 1}}); + expect(await readFile(path, 'utf8')).toBe('{\n "server": {\n "port": 1\n }\n}\n'); + await saveConfig(path, {server: {port: 2}}); + await expect(loadConfig(path)).resolves.toEqual({server: {port: 2}}); + expect(await readdir(join(folder, 'nested'))).toEqual(['config.json']); + }); + + it.skipIf(process.platform === 'win32')( + 'keeps the permissions of the file it replaces', + async () => { + const folder = await directory(); + const path = join(folder, 'secret.json'); + await saveConfig(path, {token: 'a'}, {mode: 0o600}); + await saveConfig(path, {token: 'b'}); + expect((await stat(path)).mode & 0o777).toBe(0o600); + }, + ); +}); diff --git a/tests/demo.test.tsx b/tests/demo.test.tsx index 3bb1623..6e95e48 100644 --- a/tests/demo.test.tsx +++ b/tests/demo.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import {describe, expect, it} from 'vitest'; -import {DemoForm} from '../src/demo/index.js'; +import {DemoForm, DemoWizard} from '../src/demo/index.js'; import {runForm} from '../src/run.js'; import {fakeTerminal, tick} from './helpers.js'; @@ -28,3 +28,30 @@ describe('demo setup flow', () => { expect(terminal.output()).toContain('✔ Features · Linting'); }); }); + +describe('demo wizard', () => { + it('asks the service step only for web services', async () => { + const terminal = fakeTerminal(); + const pending = runForm>( + ({submit, cancel}) => , + terminal, + ); + await tick(); + // name, kind (service), package manager, features, port, token, git, review + const keys = ['api', '\r', '\u001B[B', '\r', '\r', '\r', '\r', '\r', 'n', '\r']; + for (const key of keys) { + terminal.press(key); + await tick(); + } + await expect(pending).resolves.toEqual({ + name: 'api', + kind: 'service', + packageManager: 'npm', + features: ['tests', 'lint'], + port: 3000, + token: '', + git: false, + }); + expect(terminal.output()).toContain('Relay Forms · done'); + }); +}); diff --git a/tests/prompt.test.tsx b/tests/prompt.test.tsx index 9be26f6..dba65d4 100644 --- a/tests/prompt.test.tsx +++ b/tests/prompt.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {render} from 'ink-testing-library'; import {describe, expect, expectTypeOf, it, vi} from 'vitest'; -import {ask, askAll, defineFields, Prompt, type Field} from '../src/prompts/index.js'; +import {ask, askAll, defineFields, Prompt, PromptList, type Field} from '../src/prompts/index.js'; import {FormCancelledError} from '../src/run.js'; import {fakeTerminal, tick} from './helpers.js'; @@ -204,3 +204,19 @@ describe('askAll', () => { expectTypeOf(answers).toEqualTypeOf<{name: string; pm: 'npm' | 'pnpm'; git: boolean}>(); }); }); + +describe('PromptList', () => { + it('returns to the previous question on Esc, keeping its answer', async () => { + const fields: Field[] = [ + {kind: 'text', name: 'first', label: 'First'}, + {kind: 'text', name: 'second', label: 'Second'}, + ]; + const onSubmit = vi.fn(); + const view = render(); + await tick(); + await press(view.stdin, 'a', enter, escape); + expect(view.lastFrame()).toContain('? First a'); + await press(view.stdin, 'b', enter, 'c', enter); + expect(onSubmit).toHaveBeenCalledWith({first: 'ab', second: 'c'}); + }); +}); diff --git a/tests/wizard.test.tsx b/tests/wizard.test.tsx new file mode 100644 index 0000000..4801e68 --- /dev/null +++ b/tests/wizard.test.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import {render} from 'ink-testing-library'; +import {describe, expect, it, vi} from 'vitest'; +import {FormCancelledError} from '../src/run.js'; +import { + collectAnswers, + runWizard, + visibleSteps, + Wizard, + type WizardStep, +} from '../src/wizard/index.js'; +import {fakeTerminal, tick} from './helpers.js'; + +const enter = '\r'; +const escape = '\u001B'; +const down = '\u001B[B'; + +const steps: WizardStep[] = [ + { + id: 'project', + title: 'Project', + fields: [ + {kind: 'text', name: 'name', label: 'Name', initial: 'demo'}, + {kind: 'select', name: 'kind', label: 'Kind', options: [{value: 'library'}, {value: 'app'}]}, + ], + }, + { + id: 'deploy', + title: 'Deploy', + description: 'Where the app runs.', + when: (answers) => answers.kind === 'app', + fields: [ + { + kind: 'select', + name: 'target', + label: 'Target', + options: [{value: 'fly'}, {value: 'docker'}], + }, + ], + }, + { + id: 'extras', + title: 'Extras', + fields: (answers) => [ + {kind: 'confirm', name: 'git', label: `Initialize git for ${String(answers.name)}?`}, + ], + }, +]; + +const press = async (stdin: {write: (data: string) => void}, ...keys: string[]) => { + for (const key of keys) { + stdin.write(key); + await tick(); + } +}; + +describe('wizard steps', () => { + it('skips steps whose condition fails and drops their answers', () => { + const answers = {name: 'demo', kind: 'library', target: 'fly', git: true}; + expect(visibleSteps(steps, answers).map((step) => step.id)).toEqual(['project', 'extras']); + expect(collectAnswers(steps, answers)).toEqual({name: 'demo', kind: 'library', git: true}); + }); +}); + +describe('Wizard', () => { + it('shows progress, skips conditional steps, and submits after review', async () => { + const onSubmit = vi.fn(); + const view = render(); + await tick(); + expect(view.lastFrame()).toContain('› Project · Extras · Review'); + expect(view.lastFrame()).toContain('step 1 of 2'); + await press(view.stdin, enter, enter); + expect(view.lastFrame()).toContain('✔ Project · › Extras'); + expect(view.lastFrame()).toContain('Initialize git for demo?'); + await press(view.stdin, 'y'); + expect(view.lastFrame()).toContain('Everything look right?'); + expect(view.lastFrame()).toContain('Kind · library'); + await press(view.stdin, enter); + expect(onSubmit).toHaveBeenCalledWith({name: 'demo', kind: 'library', git: true}); + }); + + it('asks a step that a changed answer makes relevant', async () => { + const onSubmit = vi.fn(); + const view = render(); + await tick(); + await press(view.stdin, enter, enter, 'n'); + // Review: pick "Change Project", keep the name, switch the kind to app. + await press(view.stdin, down, enter, enter, down, enter); + expect(view.lastFrame()).toContain('Where the app runs.'); + await press(view.stdin, down, enter); + expect(view.lastFrame()).toContain('Target · docker'); + await press(view.stdin, enter); + expect(onSubmit).toHaveBeenCalledWith({ + name: 'demo', + kind: 'app', + target: 'docker', + git: false, + }); + }); + + it('returns to the last question of the previous step on Esc', async () => { + const view = render( undefined} />); + await tick(); + await press(view.stdin, 'x', enter, enter); + expect(view.lastFrame()).toContain('› Extras'); + await press(view.stdin, escape); + expect(view.lastFrame()).toContain('› Project'); + expect(view.lastFrame()).toContain('✔ Name · demox'); + expect(view.lastFrame()).toContain('? Kind'); + }); + + it('submits after the last step when review is off', async () => { + const onSubmit = vi.fn(); + const view = render( + , + ); + await tick(); + await press(view.stdin, enter, enter, 'y'); + expect(onSubmit).toHaveBeenCalledWith({name: 'kept', kind: 'library', git: true}); + }); +}); + +describe('runWizard', () => { + it('resolves with the answers and leaves a summary in the terminal', async () => { + const terminal = fakeTerminal(); + const pending = runWizard(steps, {...terminal, title: 'Create', review: false}); + await tick(); + for (const key of [enter, enter, 'y']) { + terminal.press(key); + await tick(); + } + await expect(pending).resolves.toEqual({name: 'demo', kind: 'library', git: true}); + expect(terminal.output()).toContain('Create · done'); + }); + + it('cancels on Esc at the first question', async () => { + const terminal = fakeTerminal(); + const pending = runWizard(steps, {...terminal, title: 'Create'}); + await tick(); + terminal.press(escape); + await expect(pending).rejects.toBeInstanceOf(FormCancelledError); + }); +});