@orkestrel/terminal 0.0.14 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,16 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let _orkestrel_console = require("@orkestrel/console");
3
3
  let _src_core = require("../core/index.cjs");
4
+ let _orkestrel_contract = require("@orkestrel/contract");
4
5
  let _orkestrel_form = require("@orkestrel/form");
5
6
  let _orkestrel_console_server = require("@orkestrel/console/server");
6
7
  let node_readline = require("node:readline");
7
8
  let node_process = require("node:process");
8
9
  //#region src/server/constants.ts
9
10
  /**
10
- * Holds the cursor-UP sequence TEMPLATE (`ESC[{count}A`) — {@link import('./helpers.js').renderCursorUp}
11
- * interpolates the `{count}` placeholder with the number of lines to climb. Kept as a template so
12
- * the count stays out of the constant.
11
+ * Holds the cursor-up sequence template (`ESC[{count}A`) —
12
+ * {@link import('./helpers.js').renderCursorUp} interpolates the `{count}` placeholder with the
13
+ * number of lines to climb. Kept as a template so the count stays out of the constant.
13
14
  */
14
15
  var CSI_UP = `${_orkestrel_console.CSI}{count}A`;
15
16
  /**
@@ -20,9 +21,9 @@ var CURSOR_HIDE = `${_orkestrel_console.CSI}?25l`;
20
21
  /** Shows the cursor (`ESC[?25h`) — restores the cursor after a prompt resolves / cancels (the {@link CURSOR_HIDE} pair). */
21
22
  var CURSOR_SHOW = `${_orkestrel_console.CSI}?25h`;
22
23
  /**
23
- * Erases from the cursor down to the end of the screen (`ESC[J`) — wipes the WHOLE previous (possibly
24
- * multi-line `select` / `checkbox`) view in one write before the new view is rendered, so a redraw
25
- * never leaves orphaned rows below.
24
+ * Erases from the cursor down to the end of the screen (`ESC[J`) — wipes the whole previous view,
25
+ * which a `select` or `checkbox` can spread over several lines, in one write before the new view is
26
+ * rendered, so a redraw never leaves orphaned rows behind.
26
27
  */
27
28
  var CLEAR_DOWN = `${_orkestrel_console.CSI}J`;
28
29
  /**
@@ -54,9 +55,15 @@ var CONTROL_HINTS = Object.freeze({
54
55
  });
55
56
  /** Holds the instruction a `file` field with `multiple` shows before its entries — one path per line, and a blank line ends the list. */
56
57
  var FILE_HINT = "One path per line, blank to finish";
57
- /** Holds the lead on the line listing an open `select`'s offered values, which a typed answer may ignore. */
58
+ /**
59
+ * Holds the lead on the line listing an open `select`'s offered values, which a typed answer can
60
+ * ignore.
61
+ */
58
62
  var SUGGESTION_LEAD = "Suggestions";
59
- /** Holds the lead on the line listing the choices a `select` or `checkbox` shows but refuses, so a reader sees why one is missing from the list below. */
63
+ /**
64
+ * Holds the lead on the line listing the choices a `select` or `checkbox` shows but refuses, so a
65
+ * reader sees why one is missing from the list it heads.
66
+ */
60
67
  var UNAVAILABLE_LEAD = "Unavailable";
61
68
  /** Holds the mark on a locked field's line — the walk renders its value and moves on, because the form refuses an edit there. */
62
69
  var LOCKED_MARK = "(locked)";
@@ -70,55 +77,55 @@ var REFUSAL_MESSAGE = "Enter a value this field accepts";
70
77
  //#endregion
71
78
  //#region src/server/helpers.ts
72
79
  /**
73
- * Checks whether `value` is a usable {@link InputStreamInterface} — a record with callable `on` / `off`
74
- * `'data'` subscription methods. A total type guard: it NEVER throws and returns `false`
75
- * for anything off-shape, so it narrows the one unavoidable input boundary (the real `process.stdin`,
76
- * or a fake TTY a test injects) to the exact slice the driver reads — no `as`.
80
+ * Checks whether `value` is a usable {@link InputStreamInterface} — a record with callable `on` /
81
+ * `off` `'data'` subscription methods. A total type guard: it never throws and returns `false` for
82
+ * anything off-shape, so it narrows the one unavoidable input boundary (the real `process.stdin`,
83
+ * or a fake TTY a test injects) to the exact slice the driver reads, never an assertion.
77
84
  *
78
85
  * @remarks
79
86
  * Only `on` / `off` are required (the irreducible event seam); `setRawMode` / `resume` / `pause` /
80
87
  * `isTTY` are optional on {@link InputStreamInterface}, so their absence does not disqualify a stream
81
- * — a piped, non-TTY stream is still a valid input, just one the driver reads through the readline
88
+ * — a piped, non-TTY stream is still a valid input, one the driver reads through the readline
82
89
  * fallback rather than raw mode.
83
90
  *
84
91
  * @param value - Any value crossing the boundary (a process stream, an injected fake, `unknown`)
85
92
  * @returns True if `value` has callable `on` and `off`; false otherwise
86
93
  */
87
94
  function isInputStream(value) {
88
- return typeof value === "object" && value !== null && "on" in value && typeof value.on === "function" && "off" in value && typeof value.off === "function";
95
+ return (0, _orkestrel_contract.isObject)(value) && "on" in value && (0, _orkestrel_contract.isFunction)(value.on) && "off" in value && (0, _orkestrel_contract.isFunction)(value.off);
89
96
  }
90
97
  /**
91
98
  * Checks whether `value` is a Node {@link NodeJS.ReadableStream} — a total structural guard
92
- * checking for the callable `read` / `pipe` / `on` that `node:readline`'s `createInterface` requires
93
- * as its `input`. The non-TTY fallback narrows the resolved input stream through this before handing
94
- * it to readline (never an `as`), so a real piped `process.stdin` (or a `PassThrough` a test injects)
95
- * crosses into the readline boundary honestly. Never throws; returns `false` for a minimal fake that
96
- * isn't a full readable.
99
+ * checking for the callable `read` / `pipe` / `on` that `node:readline`'s `createInterface`
100
+ * requires as its `input`. The non-TTY fallback narrows the resolved input stream through this
101
+ * before handing it to readline, never through an assertion, so a real piped `process.stdin` (or a
102
+ * `PassThrough` a test injects) crosses into the readline boundary honestly. Never throws; returns
103
+ * `false` for a minimal fake that isn't a full readable.
97
104
  *
98
105
  * @param value - The resolved input stream (or any value crossing the boundary)
99
106
  * @returns True if `value` has the readable methods readline needs; false otherwise
100
107
  */
101
108
  function isReadable(value) {
102
- return typeof value === "object" && value !== null && "read" in value && typeof value.read === "function" && "pipe" in value && typeof value.pipe === "function";
109
+ return (0, _orkestrel_contract.isObject)(value) && "read" in value && (0, _orkestrel_contract.isFunction)(value.read) && "pipe" in value && (0, _orkestrel_contract.isFunction)(value.pipe);
103
110
  }
104
111
  /**
105
- * Checks whether an input stream can be driven in RAW mode — it both reports `isTTY === true` AND
106
- * exposes a callable `setRawMode`. The {@link import('./Terminal.js').Terminal} probes this to choose its path:
107
- * `true` ⇒ the interactive raw-mode prompts (arrow-key navigation, live re-render); `false` ⇒ the
108
- * `node:readline` line-input fallback (a piped / non-terminal stream cannot enter raw mode). Total —
109
- * never throws.
112
+ * Checks whether an input stream can be driven in raw mode — it reports `isTTY === true` and
113
+ * exposes a callable `setRawMode`. The {@link import('./Terminal.js').Terminal} probes this to
114
+ * choose its path: true selects the interactive raw-mode fields, with arrow-key navigation and a
115
+ * live re-render; false selects the `node:readline` line-input fallback, because a piped or
116
+ * non-terminal stream cannot enter raw mode. Total — never throws.
110
117
  *
111
118
  * @param input - The resolved {@link InputStreamInterface}
112
119
  * @returns True if the stream is a TTY with `setRawMode`; false otherwise
113
120
  */
114
121
  function supportsRawMode(input) {
115
- return input.isTTY === true && typeof input.setRawMode === "function";
122
+ return input.isTTY === true && (0, _orkestrel_contract.isFunction)(input.setRawMode);
116
123
  }
117
124
  /**
118
- * Counts the terminal LINES a rendered prompt `view` occupies — one more than its newline count
119
- * (a view with no newline is a single line; N newlines span N+1 lines). The basis of the in-place
120
- * re-render: the driver records the line count of the view it just wrote so the next redraw knows how
121
- * far up to move the cursor before overwriting. Total; an empty string is one (empty) line.
125
+ * Counts the terminal lines a rendered prompt `view` occupies — one more than its newline count, so
126
+ * a view with no newline is a single line and a view with N newlines spans N+1 lines. The basis of
127
+ * the in-place re-render: the driver records the line count of the view it wrote so the next redraw
128
+ * knows how far up to move the cursor before overwriting. Total; an empty string is one empty line.
122
129
  *
123
130
  * @param view - The rendered (possibly multi-line, possibly ANSI-styled) view string
124
131
  * @returns The number of lines the view spans (always at least 1)
@@ -129,10 +136,10 @@ function lineCount(view) {
129
136
  return lines;
130
137
  }
131
138
  /**
132
- * Returns the cursor-UP control sequence that moves the cursor up `count` lines (`ESC[{count}A`) — or the
133
- * empty string when `count` is zero or negative (no movement needed, and `ESC[0A` is a wasted write).
134
- * The pure step the in-place re-render uses to climb back over the previous view before clearing it.
135
- * Total.
139
+ * Returns the cursor-up control sequence that moves the cursor up `count` lines (`ESC[{count}A`),
140
+ * or the empty string when `count` is zero or negative, because no movement is needed and `ESC[0A`
141
+ * is a wasted write. The pure step the in-place re-render uses to climb back over the previous view
142
+ * before clearing it. Total.
136
143
  *
137
144
  * @param count - How many lines to move the cursor up
138
145
  * @returns The `ESC[{count}A` sequence, or `''` when `count <= 0`
@@ -142,31 +149,31 @@ function renderCursorUp(count) {
142
149
  return `${CSI_UP.replace("{count}", String(count))}`;
143
150
  }
144
151
  /**
145
- * Returns the full reposition-and-clear prefix to write BEFORE re-rendering a prompt view in place — given
146
- * the line count of the PREVIOUS view, it moves the cursor up over those lines, returns it to column
147
- * 0, and erases everything from there to the end of the screen, so the next view is drawn on a clean
148
- * region (no orphaned rows from a taller previous view). Pure; the driver writes this immediately
149
- * followed by the new view.
152
+ * Returns the full reposition-and-clear prefix to write before re-rendering a prompt view in place
153
+ * — given the line count of the previous view, it moves the cursor up over those lines, returns it
154
+ * to column 0, and erases everything from there to the end of the screen, so the next view is drawn
155
+ * on a clean region and a taller previous view leaves no orphaned rows. Pure; the driver writes
156
+ * this immediately followed by the new view.
150
157
  *
151
158
  * @remarks
152
- * For the FIRST render `previousLines` is `1` (the cursor sits on the line the prompt opened on) so
153
- * the prefix is just a carriage return + clear-down — the prompt draws from the current line. For a
154
- * subsequent render it climbs `previousLines - 1` lines (the cursor is on the LAST line of the prior
159
+ * For the first render `previousLines` is `1` (the cursor sits on the line the prompt opened on) so
160
+ * the prefix is a carriage return + clear-down — the prompt draws from the current line. For a
161
+ * subsequent render it climbs `previousLines - 1` lines (the cursor is on the last line of the prior
155
162
  * view) before clearing. Keeping the math here (not in the driver) makes the re-render unit-testable
156
163
  * without a real terminal.
157
164
  *
158
- * @param previousLines - The line count of the view currently on screen (from {@link lineCount})
165
+ * @param previousLines - The line count of the view on screen (from {@link lineCount})
159
166
  * @returns The control-sequence prefix to write before the new view
160
167
  */
161
168
  function redrawPrefix(previousLines) {
162
169
  return `${renderCursorUp(previousLines - 1)}${_src_core.RETURN}${CLEAR_DOWN}`;
163
170
  }
164
171
  /**
165
- * Projects any field the walk reads as a LINE OF TEXT into the {@link TextField} the text reducer
166
- * takes — `text` itself, and the controls a terminal has no widget for: `number`, `date`,
167
- * `time`, `datetime`, `color`, and one `file` entry. The label carries that control's format cue
168
- * from {@link CONTROL_HINTS}, and a declared `default` becomes the line a bare return submits. The
169
- * projection carries no rule, because the AUTHORITATIVE form still evaluates the answer this line
172
+ * Projects any field the walk reads as a line of text into the {@link TextField} the text reducer
173
+ * takes — `text` itself, and the controls a terminal has no widget for: `number`, `date`, `time`,
174
+ * `datetime`, `color`, and one `file` entry. The label carries that control's format cue from
175
+ * {@link CONTROL_HINTS}, and a declared `default` becomes the line a bare return submits. The
176
+ * projection carries no rule, because the authoritative form still evaluates the answer this line
170
177
  * binds; it exists only so one reducer covers every one of them.
171
178
  *
172
179
  * @param field - The field being read
@@ -182,12 +189,12 @@ function fieldToText(field) {
182
189
  const label = field.label ?? field.name;
183
190
  const hint = CONTROL_HINTS[field.control];
184
191
  const seed = "default" in field ? field.default : void 0;
185
- const text = typeof seed === "number" ? String(seed) : seed;
192
+ const text = (0, _orkestrel_contract.isNumber)(seed) ? String(seed) : seed;
186
193
  return {
187
194
  control: "text",
188
195
  name: field.name,
189
196
  label: hint === void 0 ? label : `${label} ${hint}`,
190
- ...typeof text === "string" ? { default: text } : {}
197
+ ...(0, _orkestrel_contract.isString)(text) ? { default: text } : {}
191
198
  };
192
199
  }
193
200
  /**
@@ -200,13 +207,13 @@ function fieldToText(field) {
200
207
  */
201
208
  function valueToText(value) {
202
209
  if (value === void 0) return "";
203
- if (typeof value === "string") return value;
204
- if (typeof value === "number") return String(value);
205
- if (typeof value === "boolean") return value ? "yes" : "no";
210
+ if ((0, _orkestrel_contract.isString)(value)) return value;
211
+ if ((0, _orkestrel_contract.isNumber)(value)) return String(value);
212
+ if ((0, _orkestrel_contract.isBoolean)(value)) return value ? "yes" : "no";
206
213
  return value.join(", ");
207
214
  }
208
215
  /**
209
- * Returns the choices a `select` or `checkbox` field actually OFFERS — the form refuses a disabled
216
+ * Returns the choices a `select` or `checkbox` field actually offers — the form refuses a disabled
210
217
  * choice's value at every door, including a fill, so the walk never puts one in front of the
211
218
  * cursor. Pair with {@link filterDisabled} to tell the reader what was withheld.
212
219
  *
@@ -217,7 +224,7 @@ function filterEnabled(choices) {
217
224
  return choices.filter((choice) => choice.disabled !== true);
218
225
  }
219
226
  /**
220
- * Returns the choices a `select` or `checkbox` field SHOWS but refuses — the complement of
227
+ * Returns the choices a `select` or `checkbox` field shows but refuses — the complement of
221
228
  * {@link filterEnabled}, rendered by {@link renderUnavailableLine} above the list so a reader sees
222
229
  * why a declared choice is missing from it.
223
230
  *
@@ -240,9 +247,9 @@ function renderGroupHeader(styler, theme, label) {
240
247
  return styler.render(theme.roles.message, label);
241
248
  }
242
249
  /**
243
- * Renders the read-only line a LOCKED field shows — its label, the {@link LOCKED_MARK}, and the
244
- * answer the form already holds. The walk writes this instead of a prompt, because the field is still
245
- * validated and still submitted but must not be edited here.
250
+ * Renders the read-only line a locked field shows — its label, the {@link LOCKED_MARK}, and the
251
+ * answer the form already holds. The walk writes this instead of a prompt, because the field is
252
+ * still validated and still submitted but must not be edited here.
246
253
  *
247
254
  * @param styler - The console styler that renders each role
248
255
  * @param theme - The resolved prompt theme
@@ -255,7 +262,7 @@ function renderLockedLine(styler, theme, label, value) {
255
262
  return value.length === 0 ? head : `${head} ${styler.render(theme.roles.content, value)}`;
256
263
  }
257
264
  /**
258
- * Renders the line listing an OPEN select's offered values above its text prompt — a suggestion
265
+ * Renders the line listing an open select's offered values above its text prompt — a suggestion
259
266
  * list, because an open select admits an answer the list does not offer.
260
267
  *
261
268
  * @param styler - The console styler that renders each role
@@ -296,12 +303,13 @@ function renderNumberedList(styler, theme, choices) {
296
303
  //#endregion
297
304
  //#region src/server/Terminal.ts
298
305
  /**
299
- * Implements {@link TerminalInterface} for a human at this machine's keyboard — the interactive form
300
- * DRIVER, and the only impure part of the terminal stack. {@link ask} walks one
301
- * form's fields in schema order, feeds raw-mode stdin bytes through `parseKey` into the matching
302
- * pure reducer, renders each returned view in place, and binds every answer through the form's own
303
- * `fill`. It owns no form logic: the schema, the rules, the values, and the settlement all belong to
304
- * the form it is given, and this class owns only raw mode, the cursor, and the re-render.
306
+ * Implements {@link TerminalInterface} for a human at this machine's keyboard — the interactive
307
+ * form driver, and the only impure part of the terminal stack. {@link ask} walks one form's fields
308
+ * in schema order, feeds raw-mode stdin bytes through `parseKey` into the matching pure reducer,
309
+ * renders each returned view in place, binds every answer through the form's own `fill`, and
310
+ * re-asks what the form refused. It owns no form logic: the schema, the rules, the values, and the
311
+ * settlement all belong to the form it is given, and this class owns only raw mode, the cursor, and
312
+ * the re-render.
305
313
  *
306
314
  * @remarks
307
315
  * See {@link TerminalInterface} for the driving contract. The walk itself:
@@ -313,12 +321,11 @@ function renderNumberedList(styler, theme, choices) {
313
321
  * `open` means the answer need not come from the list.
314
322
  * - **The binding projects through `matchesAnswer`.** Every answer is filled as
315
323
  * `fill(name, matchesAnswer(value) ? value : undefined)` after `parseValue` has coerced it to the
316
- * control's own shape, so a bare return on a field with no default binds as ABSENCE and the
324
+ * control's own shape, so a bare return on a field with no default binds as absence and the
317
325
  * form's `required` rule refuses it. A typed answer the control cannot hold binds as absence and
318
326
  * invalidates the field, so the walk asks again with the reason on screen.
319
- * - **Visibility is honored.** A `hidden` field and a field currently in `form.disabled` are
320
- * skipped; a `locked` field renders read-only; entering a new group writes its label as a section
321
- * header.
327
+ * - **Visibility is honored.** A `hidden` field and a field in `form.disabled` are skipped; a
328
+ * `locked` field renders read-only; entering a new group writes its label as a section header.
322
329
  * - **Refusal re-asks.** After the walk the form is submitted. A refusal re-walks only the erroring
323
330
  * fields the walk can edit and submits again. When every erroring field is one the walk cannot
324
331
  * edit — hidden, locked, or disabled — the form is abandoned instead, because asking again could
@@ -746,12 +753,12 @@ var Terminal = class {
746
753
  //#endregion
747
754
  //#region src/server/factories.ts
748
755
  /**
749
- * Creates the interactive terminal form driver — the local-keyboard arm of the terminal trio, beside
750
- * the core headless `createPrompt` broker and the SSE `createPromptClient` bridge. Where the broker
751
- * PARKS a live form until somebody elsewhere answers it, a `Terminal` answers one HERE: it walks the
752
- * form's fields in schema order, drives each control's pure reducer over raw-mode stdin, binds every
753
- * answer through the form's own `fill`, and submits. It is the only impure part of the terminal
754
- * stack.
756
+ * Creates the interactive terminal form driver — the local-keyboard arm of the terminal trio,
757
+ * beside the core headless `createPrompt` broker and the SSE `createPromptClient` bridge. Where the
758
+ * broker parks a live form until somebody elsewhere answers it, a `Terminal` answers one here: it
759
+ * walks the form's fields in schema order, drives each control's pure reducer over raw-mode stdin,
760
+ * binds every answer through the form's own `fill`, and submits. It is the only impure part of the
761
+ * terminal stack.
755
762
  *
756
763
  * @param options - See {@link TerminalOptions}
757
764
  * @returns A {@link TerminalInterface} whose `ask` drives one whole form over the resolved streams
@@ -771,21 +778,39 @@ var Terminal = class {
771
778
  * - **Non-TTY fallback.** A piped stream cannot enter raw mode, so the same walk runs over
772
779
  * `node:readline` line input.
773
780
  *
774
- * @example
781
+ * @example Ask one form at this keyboard
775
782
  * ```ts
776
783
  * import { createForm } from '@orkestrel/form'
784
+ * import { isTerminalError } from '@orkestrel/terminal'
777
785
  * import { createTerminal } from '@orkestrel/terminal/server'
778
786
  *
779
- * const terminal = createTerminal()
780
- * const values = await terminal.ask(
781
- * createForm({
782
- * label: 'Sign up',
783
- * fields: [
784
- * { control: 'text', name: 'email', label: 'Email', rule: { required: true, email: true } },
785
- * { control: 'confirm', name: 'terms', label: 'I accept the terms' },
786
- * ],
787
- * }),
788
- * )
787
+ * const terminal = createTerminal() // process.stdin / process.stdout by default
788
+ * const form = createForm({
789
+ * label: 'Sign up',
790
+ * fields: [
791
+ * { control: 'text', name: 'name', label: 'Your name', rule: { required: true, minimum: 2 } },
792
+ * { control: 'text', name: 'email', label: 'Email', rule: { required: true, email: true } },
793
+ * { control: 'password', name: 'token', label: 'Token' },
794
+ * { control: 'confirm', name: 'terms', label: 'Accept the terms', rule: { required: true } },
795
+ * {
796
+ * control: 'select',
797
+ * name: 'role',
798
+ * label: 'Role',
799
+ * choices: [
800
+ * { value: 'admin', label: 'Admin' },
801
+ * { value: 'viewer', label: 'Viewer', help: 'read-only' },
802
+ * ],
803
+ * },
804
+ * ],
805
+ * })
806
+ *
807
+ * try {
808
+ * const values = await terminal.ask(form)
809
+ * deploy(values)
810
+ * } catch (error) {
811
+ * // Ctrl-c: the walk ended, and the form is still `editing` for whoever owns it.
812
+ * if (isTerminalError(error) && error.code === 'CANCEL') form.destroy()
813
+ * }
789
814
  * ```
790
815
  */
791
816
  function createTerminal(options) {