Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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})`.
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
47 changes: 47 additions & 0 deletions docs/config-editor.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
62 changes: 62 additions & 0 deletions docs/wizard.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 5 additions & 4 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -19,20 +19,21 @@ const main = async (): Promise<void> => {
}
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(<DemoForm interactive={false} />, {columns})}\n`);
process.stdout.write(`${renderToString(<Demo interactive={false} />, {columns})}\n`);
return;
}

try {
const answers = await runForm<Record<string, unknown>>(({submit, cancel}) => (
<DemoForm onSubmit={submit} onCancel={cancel} />
<Demo onSubmit={submit} onCancel={cancel} />
));
process.stdout.write(`\n${JSON.stringify(answers, undefined, 2)}\n`);
} catch (error) {
Expand Down
35 changes: 27 additions & 8 deletions src/cli/options.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading