@orkestrel/terminal 0.0.1
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.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/src/core/index.cjs +1739 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +1273 -0
- package/dist/src/core/index.d.ts +1273 -0
- package/dist/src/core/index.js +1651 -0
- package/dist/src/core/index.js.map +1 -0
- package/dist/src/server/index.cjs +478 -0
- package/dist/src/server/index.cjs.map +1 -0
- package/dist/src/server/index.d.cts +321 -0
- package/dist/src/server/index.d.ts +321 -0
- package/dist/src/server/index.js +458 -0
- package/dist/src/server/index.js.map +1 -0
- package/package.json +97 -0
|
@@ -0,0 +1,1739 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _orkestrel_contract = require("@orkestrel/contract");
|
|
3
|
+
let _orkestrel_console = require("@orkestrel/console");
|
|
4
|
+
let _orkestrel_emitter = require("@orkestrel/emitter");
|
|
5
|
+
let _orkestrel_sse = require("@orkestrel/sse");
|
|
6
|
+
//#region src/core/constants.ts
|
|
7
|
+
/** Carriage return (`\r`, U+000D) — Enter on most terminals. */
|
|
8
|
+
var RETURN = String.fromCharCode(13);
|
|
9
|
+
/** Line feed (`\n`, U+000A) — Enter on some terminals / pasted input. */
|
|
10
|
+
var NEWLINE = String.fromCharCode(10);
|
|
11
|
+
/** Tab (`\t`, U+0009). */
|
|
12
|
+
var TAB = String.fromCharCode(9);
|
|
13
|
+
/** Escape (ESC, U+001B) — the lone byte, and the lead byte of every CSI / SS3 sequence. */
|
|
14
|
+
var ESCAPE = String.fromCharCode(27);
|
|
15
|
+
/** Backspace (BS, U+0008) — Ctrl+H / some terminals' Backspace. */
|
|
16
|
+
var BACKSPACE = String.fromCharCode(8);
|
|
17
|
+
/** Delete (DEL, U+007F) — the usual Backspace byte on a Unix TTY. */
|
|
18
|
+
var DELETE = String.fromCharCode(127);
|
|
19
|
+
/** Space (U+0020). */
|
|
20
|
+
var SPACE = " ";
|
|
21
|
+
/** Ctrl+C (ETX, U+0003) — interrupt / cancel. */
|
|
22
|
+
var CTRL_C = String.fromCharCode(3);
|
|
23
|
+
/** Ctrl+D (EOT, U+0004) — end-of-transmission / finish (the editor's commit key). */
|
|
24
|
+
var CTRL_D = String.fromCharCode(4);
|
|
25
|
+
/** Ctrl+U (NAK, U+0015) — clear the current line. */
|
|
26
|
+
var CTRL_U = String.fromCharCode(21);
|
|
27
|
+
/** Ctrl+A (SOH, U+0001) — move to start of line. */
|
|
28
|
+
var CTRL_A = String.fromCharCode(1);
|
|
29
|
+
/** Ctrl+E (ENQ, U+0005) — move to end of line. */
|
|
30
|
+
var CTRL_E = String.fromCharCode(5);
|
|
31
|
+
/**
|
|
32
|
+
* The Control Sequence Introducer lead (`ESC[`) for the navigation keys — the prefix of the
|
|
33
|
+
* arrow / home / end / delete sequences {@link SEQUENCE_NAMES} is keyed by. Named `KEY_CSI`
|
|
34
|
+
* (not `CSI`) so it never collides with the console module's SGR `CSI` (both barrel through
|
|
35
|
+
* `@src/core`).
|
|
36
|
+
*/
|
|
37
|
+
var KEY_CSI = `[`;
|
|
38
|
+
/** The Single Shift Three lead (`ESCO`) — the alternate arrow-key prefix some terminals emit (`ESC O A`). */
|
|
39
|
+
var KEY_SS3 = `O`;
|
|
40
|
+
/**
|
|
41
|
+
* The exact escape SEQUENCE → canonical key NAME table {@link import('./helpers.js').parseKey}
|
|
42
|
+
* consults for the navigation / editing keys. Covers BOTH the CSI form (`ESC[A`…) and the SS3
|
|
43
|
+
* form (`ESCOA`…) of the four arrows, plus the `home` / `end` / `delete` CSI sequences (with
|
|
44
|
+
* their numeric-tilde variants). The source of truth for the multi-byte key decode; frozen.
|
|
45
|
+
*
|
|
46
|
+
* @remarks
|
|
47
|
+
* Terminals disagree on these: a cursor key is `ESC[A` (normal) or `ESCOA` (application mode),
|
|
48
|
+
* and Home / End / Delete each have a letter form (`ESC[H` / `ESC[F`) and a numeric form
|
|
49
|
+
* (`ESC[1~` / `ESC[4~` / `ESC[3~`). Every accepted spelling maps to one name so a reducer never
|
|
50
|
+
* sees the wire encoding.
|
|
51
|
+
*/
|
|
52
|
+
var SEQUENCE_NAMES = Object.freeze({
|
|
53
|
+
[`${KEY_CSI}A`]: "up",
|
|
54
|
+
[`${KEY_CSI}B`]: "down",
|
|
55
|
+
[`${KEY_CSI}C`]: "right",
|
|
56
|
+
[`${KEY_CSI}D`]: "left",
|
|
57
|
+
[`${KEY_SS3}A`]: "up",
|
|
58
|
+
[`${KEY_SS3}B`]: "down",
|
|
59
|
+
[`${KEY_SS3}C`]: "right",
|
|
60
|
+
[`${KEY_SS3}D`]: "left",
|
|
61
|
+
[`${KEY_CSI}H`]: "home",
|
|
62
|
+
[`${KEY_CSI}F`]: "end",
|
|
63
|
+
[`${KEY_CSI}1~`]: "home",
|
|
64
|
+
[`${KEY_CSI}4~`]: "end",
|
|
65
|
+
[`${KEY_CSI}3~`]: "delete",
|
|
66
|
+
[`${KEY_CSI}7~`]: "home",
|
|
67
|
+
[`${KEY_CSI}8~`]: "end"
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* The single control BYTE → key descriptor table {@link import('./helpers.js').parseKey}
|
|
71
|
+
* consults for the one-byte keys. Each entry carries the canonical `name` and whether it is a
|
|
72
|
+
* `ctrl` combination. The source of truth for the single-byte key decode; frozen.
|
|
73
|
+
*
|
|
74
|
+
* @remarks
|
|
75
|
+
* `return` / `newline` map to `return` (one canonical Enter name); `delete` / `backspace` both
|
|
76
|
+
* map to `backspace` (the two Backspace bytes); the Ctrl combos (`c` / `d` / `u` / `a` / `e`)
|
|
77
|
+
* carry `ctrl: true` so a reducer can match `key.ctrl && key.name === 'c'`. `escape` / `tab` /
|
|
78
|
+
* `space` are plain named keys.
|
|
79
|
+
*/
|
|
80
|
+
var CONTROL_NAMES = Object.freeze({
|
|
81
|
+
["\r"]: Object.freeze({
|
|
82
|
+
name: "return",
|
|
83
|
+
ctrl: false
|
|
84
|
+
}),
|
|
85
|
+
["\n"]: Object.freeze({
|
|
86
|
+
name: "return",
|
|
87
|
+
ctrl: false
|
|
88
|
+
}),
|
|
89
|
+
[" "]: Object.freeze({
|
|
90
|
+
name: "tab",
|
|
91
|
+
ctrl: false
|
|
92
|
+
}),
|
|
93
|
+
["\x1B"]: Object.freeze({
|
|
94
|
+
name: "escape",
|
|
95
|
+
ctrl: false
|
|
96
|
+
}),
|
|
97
|
+
["\b"]: Object.freeze({
|
|
98
|
+
name: "backspace",
|
|
99
|
+
ctrl: false
|
|
100
|
+
}),
|
|
101
|
+
[""]: Object.freeze({
|
|
102
|
+
name: "backspace",
|
|
103
|
+
ctrl: false
|
|
104
|
+
}),
|
|
105
|
+
[" "]: Object.freeze({
|
|
106
|
+
name: "space",
|
|
107
|
+
ctrl: false
|
|
108
|
+
}),
|
|
109
|
+
[""]: Object.freeze({
|
|
110
|
+
name: "c",
|
|
111
|
+
ctrl: true
|
|
112
|
+
}),
|
|
113
|
+
[""]: Object.freeze({
|
|
114
|
+
name: "d",
|
|
115
|
+
ctrl: true
|
|
116
|
+
}),
|
|
117
|
+
[""]: Object.freeze({
|
|
118
|
+
name: "u",
|
|
119
|
+
ctrl: true
|
|
120
|
+
}),
|
|
121
|
+
[""]: Object.freeze({
|
|
122
|
+
name: "a",
|
|
123
|
+
ctrl: true
|
|
124
|
+
}),
|
|
125
|
+
[""]: Object.freeze({
|
|
126
|
+
name: "e",
|
|
127
|
+
ctrl: true
|
|
128
|
+
})
|
|
129
|
+
});
|
|
130
|
+
/** The default mask glyph a {@link import('./types.js').PasswordState} renders each input character as — `*`. */
|
|
131
|
+
var DEFAULT_MASK = "*";
|
|
132
|
+
/** Matches an email address — a non-trivial `local@domain.tld` shape. The `email` rule tests against this. */
|
|
133
|
+
var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
134
|
+
/** Matches an HTTP(S) URL. The `url` rule tests against this. */
|
|
135
|
+
var URL_PATTERN = /^https?:\/\/.+/;
|
|
136
|
+
/** Matches a numeric value (integer or decimal, optional sign). The `numeric` rule tests against this. */
|
|
137
|
+
var NUMERIC_PATTERN = /^-?\d+(\.\d+)?$/;
|
|
138
|
+
/** Matches an integer (optional sign). The `integer` rule tests against this. */
|
|
139
|
+
var INTEGER_PATTERN = /^-?\d+$/;
|
|
140
|
+
/** Matches an alphanumeric string (letters and digits only). The `alphanumeric` rule tests against this. */
|
|
141
|
+
var ALPHANUMERIC_PATTERN = /^[a-zA-Z0-9]+$/;
|
|
142
|
+
/**
|
|
143
|
+
* Each built-in validation rule's default error message — what the composed {@link
|
|
144
|
+
* import('./types.js').Validator} returns when that rule fails (the `minimum` / `maximum`
|
|
145
|
+
* messages are interpolated with the configured length at build time). Frozen; the source of
|
|
146
|
+
* truth for the rule-failure copy.
|
|
147
|
+
*/
|
|
148
|
+
var RULE_MESSAGES = Object.freeze({
|
|
149
|
+
required: "This field is required",
|
|
150
|
+
minimum: "Must be at least {count} characters",
|
|
151
|
+
maximum: "Must be at most {count} characters",
|
|
152
|
+
pattern: "Must match pattern: {pattern}",
|
|
153
|
+
email: "Must be a valid email address",
|
|
154
|
+
url: "Must be a valid URL",
|
|
155
|
+
numeric: "Must be a numeric value",
|
|
156
|
+
integer: "Must be an integer",
|
|
157
|
+
alphanumeric: "Must contain only letters and digits",
|
|
158
|
+
invalid: "Invalid input"
|
|
159
|
+
});
|
|
160
|
+
/**
|
|
161
|
+
* The prompt-view icon glyphs the reducers render the prompt line and choice rows with. PLAIN
|
|
162
|
+
* glyphs — color is applied by the {@link import('./types.js').PromptState}'s
|
|
163
|
+
* {@link import('@orkestrel/console').StylerInterface} at render time, never baked into the
|
|
164
|
+
* constant (AGENTS — styling orthogonal to data; the rework fixes the terminal's ANSI-in-the-icon).
|
|
165
|
+
* Frozen.
|
|
166
|
+
*
|
|
167
|
+
* @remarks
|
|
168
|
+
* - `question` — the leading mark on a prompt's message line.
|
|
169
|
+
* - `pointer` — the cursor before the input / the focused choice row.
|
|
170
|
+
* - `dot` / `selected` — an unfocused / focused row marker in a select list.
|
|
171
|
+
* - `checked` / `unchecked` — a checked / unchecked box in a checkbox list.
|
|
172
|
+
*/
|
|
173
|
+
var PROMPT_ICONS = Object.freeze({
|
|
174
|
+
question: "?",
|
|
175
|
+
pointer: "›",
|
|
176
|
+
dot: "○",
|
|
177
|
+
selected: "●",
|
|
178
|
+
checked: "☑",
|
|
179
|
+
unchecked: "☐"
|
|
180
|
+
});
|
|
181
|
+
/** How long (ms) the {@link import('./types.js').PromptInterface} broker parks an unanswered prompt before it expires — 5 minutes. */
|
|
182
|
+
var DEFAULT_PROMPT_TIMEOUT_MS = 3e5;
|
|
183
|
+
/** How long (ms) the {@link import('./types.js').PromptClientInterface} waits before each reconnect attempt — 2 seconds. */
|
|
184
|
+
var DEFAULT_RECONNECT_DELAY_MS = 2e3;
|
|
185
|
+
/**
|
|
186
|
+
* The SSE `event:` names the broker emits and the {@link import('./types.js').PromptClient}
|
|
187
|
+
* dispatches on. Frozen; the source of truth for the wire event vocabulary.
|
|
188
|
+
*
|
|
189
|
+
* @remarks
|
|
190
|
+
* - `pending` — a serialized {@link import('./types.js').PendingPrompt} to dispatch + answer.
|
|
191
|
+
* - `expire` — an `{ id }` payload: the broker expired a parked prompt (the client drops it).
|
|
192
|
+
* - `shutdown` — the broker is going away; the client disconnects (no auto-reconnect) but stays reusable.
|
|
193
|
+
*/
|
|
194
|
+
var SSE_EVENTS = Object.freeze({
|
|
195
|
+
pending: "pending",
|
|
196
|
+
expire: "expire",
|
|
197
|
+
shutdown: "shutdown"
|
|
198
|
+
});
|
|
199
|
+
/** The auth-token request header the {@link import('./types.js').PromptClient} sends when a `token` is configured. */
|
|
200
|
+
var HEADER_TOKEN = "x-orkestrel-token";
|
|
201
|
+
/** The `Accept` header value that opens the broker's SSE stream. */
|
|
202
|
+
var ACCEPT_EVENT_STREAM = "text/event-stream";
|
|
203
|
+
/**
|
|
204
|
+
* The maximum number of characters the {@link import('./types.js').PromptClient} lets its
|
|
205
|
+
* SSE parser buffer before treating the stream as hostile — 1 MiB, comfortably above any
|
|
206
|
+
* legitimate prompt payload. Passed as the `limit` to `createSSEParser` so an unterminated
|
|
207
|
+
* or oversized `data:` field cannot grow the buffer without bound (a memory-exhaustion guard).
|
|
208
|
+
*/
|
|
209
|
+
var SSE_BUFFER_LIMIT = 1048576;
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region src/core/errors.ts
|
|
212
|
+
/**
|
|
213
|
+
* An error a {@link import('./Prompt.js').Prompt} broker rejects a parked prompt's Promise with.
|
|
214
|
+
*
|
|
215
|
+
* @remarks
|
|
216
|
+
* Carries a {@link TerminalErrorCode} and an optional `context` bag (the prompt `id`). Thrown —
|
|
217
|
+
* as a Promise rejection on the awaited prompt call — when a parked prompt is not answered before
|
|
218
|
+
* its `timeout`, or when the broker is `destroy`ed while the prompt is still `pending` (both
|
|
219
|
+
* `EXPIRE`); or when the user aborts an interactive server-`Terminal` prompt with ctrl-c
|
|
220
|
+
* (`CANCEL`). Narrow a caught value with {@link isTerminalError} and branch on `error.code`.
|
|
221
|
+
*/
|
|
222
|
+
var TerminalError = class extends Error {
|
|
223
|
+
/** The machine-readable condition — see {@link TerminalErrorCode}. */
|
|
224
|
+
code;
|
|
225
|
+
/** An optional context bag naming the offending prompt id. */
|
|
226
|
+
context;
|
|
227
|
+
constructor(code, message, context) {
|
|
228
|
+
super(message);
|
|
229
|
+
this.name = "TerminalError";
|
|
230
|
+
this.code = code;
|
|
231
|
+
this.context = context;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* Narrow an unknown caught value to a {@link TerminalError}.
|
|
236
|
+
*
|
|
237
|
+
* @param value - The value to test (typically a `catch` binding or a rejected prompt call)
|
|
238
|
+
* @returns `true` when `value` is a {@link TerminalError}
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* ```ts
|
|
242
|
+
* try {
|
|
243
|
+
* const name = await prompt.input({ message: 'Your name' })
|
|
244
|
+
* } catch (error) {
|
|
245
|
+
* if (isTerminalError(error) && error.code === 'EXPIRE') retryLater()
|
|
246
|
+
* }
|
|
247
|
+
* ```
|
|
248
|
+
*/
|
|
249
|
+
function isTerminalError(value) {
|
|
250
|
+
return value instanceof TerminalError;
|
|
251
|
+
}
|
|
252
|
+
//#endregion
|
|
253
|
+
//#region src/core/helpers.ts
|
|
254
|
+
/**
|
|
255
|
+
* Decode one keypress's bytes into a {@link KeyEvent} — total, never throws. A `Uint8Array` is
|
|
256
|
+
* read as UTF-8; the resulting string is matched against the known control bytes
|
|
257
|
+
* ({@link CONTROL_NAMES}) and escape sequences ({@link SEQUENCE_NAMES}), falling back to a
|
|
258
|
+
* single printable character. An unrecognized sequence yields `name: ''` with the raw `sequence`
|
|
259
|
+
* preserved.
|
|
260
|
+
*
|
|
261
|
+
* @remarks
|
|
262
|
+
* - **Single control byte.** A one-character control input (`return` / `backspace` / `tab` /
|
|
263
|
+
* `escape` / `space`, or a Ctrl combo `c` / `d` / `u` / `a` / `e`) is looked up in
|
|
264
|
+
* {@link CONTROL_NAMES}, carrying its `ctrl` flag.
|
|
265
|
+
* - **Escape sequence.** A multi-byte ESC sequence (`up` / `down` / `left` / `right` in BOTH the
|
|
266
|
+
* `ESC[A` and `ESCOA` forms, plus `home` / `end` / `delete`) is looked up in
|
|
267
|
+
* {@link SEQUENCE_NAMES} and flagged `meta`.
|
|
268
|
+
* - **Printable character.** A single printable character becomes `name` = that character, with
|
|
269
|
+
* `shift` set when it is an uppercase letter. A multi-code-point printable (an emoji, a pasted
|
|
270
|
+
* run) keeps its first code point as the name and the whole input as `sequence`.
|
|
271
|
+
* - **Unknown.** Anything else (an unrecognized escape, an empty input) yields `name: ''` —
|
|
272
|
+
* total, so the driver never crashes on a stray byte.
|
|
273
|
+
*
|
|
274
|
+
* @param input - The raw keypress bytes, as a string or `Uint8Array`
|
|
275
|
+
* @returns The decoded {@link KeyEvent}
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* ```ts
|
|
279
|
+
* parseKey('\r') // { name: 'return', sequence: '\r', ctrl: false, meta: false, shift: false }
|
|
280
|
+
* parseKey('\x1b[A') // { name: 'up', sequence: '\x1b[A', ctrl: false, meta: true, shift: false }
|
|
281
|
+
* parseKey('A') // { name: 'A', sequence: 'A', ctrl: false, meta: false, shift: true }
|
|
282
|
+
* parseKey('\x03') // { name: 'c', sequence: '\x03', ctrl: true, meta: false, shift: false }
|
|
283
|
+
* ```
|
|
284
|
+
*/
|
|
285
|
+
function parseKey(input) {
|
|
286
|
+
const sequence = (0, _orkestrel_contract.isString)(input) ? input : new TextDecoder().decode(input);
|
|
287
|
+
const sequenceName = SEQUENCE_NAMES[sequence];
|
|
288
|
+
if (sequenceName !== void 0) return {
|
|
289
|
+
name: sequenceName,
|
|
290
|
+
sequence,
|
|
291
|
+
ctrl: false,
|
|
292
|
+
meta: true,
|
|
293
|
+
shift: false
|
|
294
|
+
};
|
|
295
|
+
const control = CONTROL_NAMES[sequence];
|
|
296
|
+
if (control !== void 0) return {
|
|
297
|
+
name: control.name,
|
|
298
|
+
sequence,
|
|
299
|
+
ctrl: control.ctrl,
|
|
300
|
+
meta: false,
|
|
301
|
+
shift: false
|
|
302
|
+
};
|
|
303
|
+
const first = [...sequence][0];
|
|
304
|
+
if (first !== void 0 && isPrintable(first)) return {
|
|
305
|
+
name: first,
|
|
306
|
+
sequence,
|
|
307
|
+
ctrl: false,
|
|
308
|
+
meta: false,
|
|
309
|
+
shift: first !== first.toLowerCase()
|
|
310
|
+
};
|
|
311
|
+
return {
|
|
312
|
+
name: "",
|
|
313
|
+
sequence,
|
|
314
|
+
ctrl: false,
|
|
315
|
+
meta: false,
|
|
316
|
+
shift: false
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/** Whether a single character is a printable (non-control) character — used by {@link parseKey}'s char fallback. */
|
|
320
|
+
function isPrintable(character) {
|
|
321
|
+
if (character.length === 0) return false;
|
|
322
|
+
const code = character.codePointAt(0);
|
|
323
|
+
if (code === void 0) return false;
|
|
324
|
+
return code >= 32 && code !== 127;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Evaluate ONE built-in validation rule against `input`, returning its error message when the
|
|
328
|
+
* rule fails or `undefined` when it passes. The atomic check {@link buildRuleValidator} wraps
|
|
329
|
+
* into a {@link Validator}. Pure.
|
|
330
|
+
*
|
|
331
|
+
* @remarks
|
|
332
|
+
* A function `check` is the custom-override path: it is called and its `true` ⇒ pass, a string
|
|
333
|
+
* ⇒ that message, anything else ⇒ the generic {@link RULE_MESSAGES.invalid}. A primitive `check`
|
|
334
|
+
* runs the named built-in: `required` (non-empty trimmed), `minimum` / `maximum` (length
|
|
335
|
+
* bounds, the message interpolated with the count), `pattern` (a regex source), and the
|
|
336
|
+
* `email` / `url` / `numeric` / `integer` / `alphanumeric` pattern tests.
|
|
337
|
+
*
|
|
338
|
+
* @param rule - The rule name (`'required'`, `'minimum'`, …)
|
|
339
|
+
* @param check - The configured value (a primitive toggle / bound / pattern, or a custom {@link Validator})
|
|
340
|
+
* @param input - The input string to test
|
|
341
|
+
* @returns The error message when the rule fails, else `undefined`
|
|
342
|
+
*/
|
|
343
|
+
function evaluateRule(rule, check, input) {
|
|
344
|
+
if (typeof check === "function") {
|
|
345
|
+
const result = check(input);
|
|
346
|
+
if (result === true) return void 0;
|
|
347
|
+
return (0, _orkestrel_contract.isString)(result) ? result : RULE_MESSAGES.invalid;
|
|
348
|
+
}
|
|
349
|
+
switch (rule) {
|
|
350
|
+
case "required":
|
|
351
|
+
if (check === true && input.trim().length === 0) return RULE_MESSAGES.required;
|
|
352
|
+
break;
|
|
353
|
+
case "minimum":
|
|
354
|
+
if ((0, _orkestrel_contract.isNumber)(check) && [...input].length < check) return RULE_MESSAGES.minimum.replace("{count}", String(check));
|
|
355
|
+
break;
|
|
356
|
+
case "maximum":
|
|
357
|
+
if ((0, _orkestrel_contract.isNumber)(check) && [...input].length > check) return RULE_MESSAGES.maximum.replace("{count}", String(check));
|
|
358
|
+
break;
|
|
359
|
+
case "pattern":
|
|
360
|
+
if ((0, _orkestrel_contract.isString)(check)) {
|
|
361
|
+
let compiled;
|
|
362
|
+
try {
|
|
363
|
+
compiled = new RegExp(check);
|
|
364
|
+
} catch {
|
|
365
|
+
return RULE_MESSAGES.pattern.replace("{pattern}", check);
|
|
366
|
+
}
|
|
367
|
+
if (!compiled.test(input)) return RULE_MESSAGES.pattern.replace("{pattern}", check);
|
|
368
|
+
}
|
|
369
|
+
break;
|
|
370
|
+
case "email":
|
|
371
|
+
if (check === true && !EMAIL_PATTERN.test(input)) return RULE_MESSAGES.email;
|
|
372
|
+
break;
|
|
373
|
+
case "url":
|
|
374
|
+
if (check === true && !URL_PATTERN.test(input)) return RULE_MESSAGES.url;
|
|
375
|
+
break;
|
|
376
|
+
case "numeric":
|
|
377
|
+
if (check === true && !NUMERIC_PATTERN.test(input)) return RULE_MESSAGES.numeric;
|
|
378
|
+
break;
|
|
379
|
+
case "integer":
|
|
380
|
+
if (check === true && !INTEGER_PATTERN.test(input)) return RULE_MESSAGES.integer;
|
|
381
|
+
break;
|
|
382
|
+
case "alphanumeric":
|
|
383
|
+
if (check === true && !ALPHANUMERIC_PATTERN.test(input)) return RULE_MESSAGES.alphanumeric;
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/** Wrap a named rule + its primitive check into a {@link Validator} (returns `true` or the rule's message). */
|
|
388
|
+
function buildRuleValidator(rule, check) {
|
|
389
|
+
return (input) => evaluateRule(rule, check, input) ?? true;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Append a rule-backed {@link Validator} to `validators` when the rule is enabled — a `false` /
|
|
393
|
+
* `undefined` `check` is skipped, a function `check` is added verbatim (the custom override), and
|
|
394
|
+
* a primitive `check` is wrapped via {@link buildRuleValidator}. Mutates `validators` in place.
|
|
395
|
+
*/
|
|
396
|
+
function appendRule(validators, rule, check) {
|
|
397
|
+
if (check === void 0 || check === false) return;
|
|
398
|
+
if (typeof check === "function") {
|
|
399
|
+
validators.push(check);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
validators.push(buildRuleValidator(rule, check));
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Compose several {@link Validator}s into ONE short-circuiting validator — it runs them in order
|
|
406
|
+
* and returns the FIRST error message, or `true` when all pass. The empty composition always
|
|
407
|
+
* passes.
|
|
408
|
+
*/
|
|
409
|
+
function composeValidators(...validators) {
|
|
410
|
+
return (input) => {
|
|
411
|
+
for (const validator of validators) {
|
|
412
|
+
const result = validator(input);
|
|
413
|
+
if (result !== true) return result;
|
|
414
|
+
}
|
|
415
|
+
return true;
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Compile a {@link Validator} or declarative {@link ValidationRules} (or nothing) into ONE
|
|
420
|
+
* composed {@link Validator}. A bare validator passes through; rules are appended in the fixed
|
|
421
|
+
* order (required → minimum → maximum → pattern → email → url → numeric → integer → alphanumeric
|
|
422
|
+
* → custom) and composed; absent / empty input yields an always-passing validator. Pure.
|
|
423
|
+
*
|
|
424
|
+
* @remarks
|
|
425
|
+
* Unlike a prior variant (which returned `Validator | undefined`), this ALWAYS returns a
|
|
426
|
+
* `Validator` — an absent or empty rule set yields a validator that returns `true` for every
|
|
427
|
+
* input. That keeps a prompt's state unconditional (it always holds a real validator to apply on
|
|
428
|
+
* submit), with no `undefined` branch at the call site.
|
|
429
|
+
*
|
|
430
|
+
* @param validate - A custom {@link Validator}, a {@link ValidationRules} bag, or `undefined`
|
|
431
|
+
* @returns The composed {@link Validator} (always-passing when nothing was supplied)
|
|
432
|
+
*
|
|
433
|
+
* @example
|
|
434
|
+
* ```ts
|
|
435
|
+
* const v = resolveValidation({ required: true, minimum: 3 })
|
|
436
|
+
* v('') // 'This field is required'
|
|
437
|
+
* v('ab') // 'Must be at least 3 characters'
|
|
438
|
+
* v('abc') // true
|
|
439
|
+
* ```
|
|
440
|
+
*/
|
|
441
|
+
function resolveValidation(validate) {
|
|
442
|
+
if (validate === void 0) return passing;
|
|
443
|
+
if (typeof validate === "function") return validate;
|
|
444
|
+
const validators = [];
|
|
445
|
+
appendRule(validators, "required", validate.required);
|
|
446
|
+
appendRule(validators, "minimum", validate.minimum);
|
|
447
|
+
appendRule(validators, "maximum", validate.maximum);
|
|
448
|
+
appendRule(validators, "pattern", validate.pattern);
|
|
449
|
+
appendRule(validators, "email", validate.email);
|
|
450
|
+
appendRule(validators, "url", validate.url);
|
|
451
|
+
appendRule(validators, "numeric", validate.numeric);
|
|
452
|
+
appendRule(validators, "integer", validate.integer);
|
|
453
|
+
appendRule(validators, "alphanumeric", validate.alphanumeric);
|
|
454
|
+
if (typeof validate.custom === "function") validators.push(validate.custom);
|
|
455
|
+
if (validators.length === 0) return passing;
|
|
456
|
+
return composeValidators(...validators);
|
|
457
|
+
}
|
|
458
|
+
/** The always-passing {@link Validator} — the resolved validator when no rules were supplied. */
|
|
459
|
+
function passing(_input) {
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
/** Normalize a select choice input into a full {@link PromptChoice} (a bare string becomes both name and value). */
|
|
463
|
+
function normalizeChoice(choice) {
|
|
464
|
+
return (0, _orkestrel_contract.isString)(choice) ? {
|
|
465
|
+
name: choice,
|
|
466
|
+
value: choice
|
|
467
|
+
} : choice;
|
|
468
|
+
}
|
|
469
|
+
/** Normalize a checkbox choice input into a full {@link CheckboxChoice} (a bare string becomes both name and value). */
|
|
470
|
+
function normalizeCheckboxChoice(choice) {
|
|
471
|
+
return (0, _orkestrel_contract.isString)(choice) ? {
|
|
472
|
+
name: choice,
|
|
473
|
+
value: choice
|
|
474
|
+
} : choice;
|
|
475
|
+
}
|
|
476
|
+
/** The styled prompt-message header (`? message`) — the leading line every active prompt view shares. */
|
|
477
|
+
function promptHeader(styler, message) {
|
|
478
|
+
return `${styler.cyan(PROMPT_ICONS.question)} ${styler.bold(message)}`;
|
|
479
|
+
}
|
|
480
|
+
/** The styled submit line (`✔ message`) — the committed header an interactive prompt shows once resolved. */
|
|
481
|
+
function submitHeader(styler, message) {
|
|
482
|
+
return `${styler.green(_orkestrel_console.STATUS_ICONS.success)} ${styler.bold(message)}`;
|
|
483
|
+
}
|
|
484
|
+
/** The styled error line (`✖ message`) — appended beneath a prompt view when the last submit failed validation. */
|
|
485
|
+
function errorLine(styler, message) {
|
|
486
|
+
return `${styler.red(_orkestrel_console.STATUS_ICONS.error)} ${styler.red(message)}`;
|
|
487
|
+
}
|
|
488
|
+
/** Build the initial {@link InputState} from {@link InputOptions} — resolving the validator + styler, seeding an empty value. */
|
|
489
|
+
function createInputState(options) {
|
|
490
|
+
return {
|
|
491
|
+
message: options.message,
|
|
492
|
+
default: options.default ?? "",
|
|
493
|
+
validator: resolveValidation(options.validate),
|
|
494
|
+
styler: options.styler ?? (0, _orkestrel_console.createStyler)(),
|
|
495
|
+
value: ""
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
/** Render an {@link InputState} as a styled view — the header, the typed value (or dimmed default hint), and any error. */
|
|
499
|
+
function inputView(state) {
|
|
500
|
+
const shown = state.value.length > 0 ? state.value : state.styler.dim(state.default);
|
|
501
|
+
const head = `${promptHeader(state.styler, state.message)} ${state.styler.cyan(PROMPT_ICONS.pointer)} ${shown}`;
|
|
502
|
+
return state.error === void 0 ? head : `${head}\n${errorLine(state.styler, state.error)}`;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Advance an input prompt by one {@link KeyEvent} — the pure `(state, key) → PromptStep<string>`
|
|
506
|
+
* reducer. Printable characters extend the value; backspace shrinks it; ctrl-u clears it; ctrl-c
|
|
507
|
+
* cancels; return submits (the empty line falls back to the default) through the validator — an
|
|
508
|
+
* invalid submit stays active with the error in the view.
|
|
509
|
+
*/
|
|
510
|
+
function inputReduce(state, key) {
|
|
511
|
+
if (key.ctrl && key.name === "c") return {
|
|
512
|
+
state,
|
|
513
|
+
view: inputView(state),
|
|
514
|
+
status: "cancel"
|
|
515
|
+
};
|
|
516
|
+
if (key.name === "return") {
|
|
517
|
+
const answer = state.value.length > 0 ? state.value : state.default;
|
|
518
|
+
const result = state.validator(answer);
|
|
519
|
+
if (result !== true) {
|
|
520
|
+
const next = {
|
|
521
|
+
...state,
|
|
522
|
+
error: result
|
|
523
|
+
};
|
|
524
|
+
return {
|
|
525
|
+
state: next,
|
|
526
|
+
view: inputView(next),
|
|
527
|
+
status: "active"
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
state: {
|
|
532
|
+
...state,
|
|
533
|
+
value: answer,
|
|
534
|
+
error: void 0
|
|
535
|
+
},
|
|
536
|
+
view: `${submitHeader(state.styler, state.message)} ${state.styler.dim(answer)}`,
|
|
537
|
+
status: "submit",
|
|
538
|
+
value: answer
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
const value = editLine(state.value, key);
|
|
542
|
+
if (value === void 0) return {
|
|
543
|
+
state,
|
|
544
|
+
view: inputView(state),
|
|
545
|
+
status: "active"
|
|
546
|
+
};
|
|
547
|
+
const next = {
|
|
548
|
+
...state,
|
|
549
|
+
value,
|
|
550
|
+
error: void 0
|
|
551
|
+
};
|
|
552
|
+
return {
|
|
553
|
+
state: next,
|
|
554
|
+
view: inputView(next),
|
|
555
|
+
status: "active"
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
/** Build the initial {@link PasswordState} from {@link PasswordOptions} — resolving the validator + styler + mask. */
|
|
559
|
+
function createPasswordState(options) {
|
|
560
|
+
return {
|
|
561
|
+
message: options.message,
|
|
562
|
+
mask: options.mask ?? "*",
|
|
563
|
+
validator: resolveValidation(options.validate),
|
|
564
|
+
styler: options.styler ?? (0, _orkestrel_console.createStyler)(),
|
|
565
|
+
value: ""
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/** Render a {@link PasswordState} as a styled view — the header, the value masked to `mask` repeated, and any error. */
|
|
569
|
+
function passwordView(state) {
|
|
570
|
+
const masked = state.mask.repeat(state.value.length);
|
|
571
|
+
const head = `${promptHeader(state.styler, state.message)} ${state.styler.cyan(PROMPT_ICONS.pointer)} ${masked}`;
|
|
572
|
+
return state.error === void 0 ? head : `${head}\n${errorLine(state.styler, state.error)}`;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Advance a password prompt by one {@link KeyEvent} — the pure `(state, key) → PromptStep<string>`
|
|
576
|
+
* reducer. Identical line-editing to {@link inputReduce} (printable extends, backspace shrinks,
|
|
577
|
+
* ctrl-u clears, ctrl-c cancels) but the view masks the value; return submits through the
|
|
578
|
+
* validator (no default fallback — a password has no echoed default).
|
|
579
|
+
*/
|
|
580
|
+
function passwordReduce(state, key) {
|
|
581
|
+
if (key.ctrl && key.name === "c") return {
|
|
582
|
+
state,
|
|
583
|
+
view: passwordView(state),
|
|
584
|
+
status: "cancel"
|
|
585
|
+
};
|
|
586
|
+
if (key.name === "return") {
|
|
587
|
+
const result = state.validator(state.value);
|
|
588
|
+
if (result !== true) {
|
|
589
|
+
const next = {
|
|
590
|
+
...state,
|
|
591
|
+
error: result
|
|
592
|
+
};
|
|
593
|
+
return {
|
|
594
|
+
state: next,
|
|
595
|
+
view: passwordView(next),
|
|
596
|
+
status: "active"
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
state: {
|
|
601
|
+
...state,
|
|
602
|
+
error: void 0
|
|
603
|
+
},
|
|
604
|
+
view: `${submitHeader(state.styler, state.message)} ${state.styler.dim(state.mask.repeat(state.value.length))}`,
|
|
605
|
+
status: "submit",
|
|
606
|
+
value: state.value
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
const value = editLine(state.value, key);
|
|
610
|
+
if (value === void 0) return {
|
|
611
|
+
state,
|
|
612
|
+
view: passwordView(state),
|
|
613
|
+
status: "active"
|
|
614
|
+
};
|
|
615
|
+
const next = {
|
|
616
|
+
...state,
|
|
617
|
+
value,
|
|
618
|
+
error: void 0
|
|
619
|
+
};
|
|
620
|
+
return {
|
|
621
|
+
state: next,
|
|
622
|
+
view: passwordView(next),
|
|
623
|
+
status: "active"
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
/** Build the initial {@link ConfirmState} from {@link ConfirmOptions} — defaulting the answer to `false`. */
|
|
627
|
+
function createConfirmState(options) {
|
|
628
|
+
return {
|
|
629
|
+
message: options.message,
|
|
630
|
+
default: options.default ?? false,
|
|
631
|
+
styler: options.styler ?? (0, _orkestrel_console.createStyler)()
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
/** Render a {@link ConfirmState} as a styled view — the header plus a `(Y/n)` hint with the default letter emphasized. */
|
|
635
|
+
function confirmView(state) {
|
|
636
|
+
const hint = state.default ? `${state.styler.green("Y")}${state.styler.dim("/n")}` : `${state.styler.dim("y/")}${state.styler.green("N")}`;
|
|
637
|
+
return `${promptHeader(state.styler, state.message)} ${state.styler.dim("(")}${hint}${state.styler.dim(")")}`;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Advance a confirm prompt by one {@link KeyEvent} — the pure `(state, key) → PromptStep<boolean>`
|
|
641
|
+
* reducer. `y` / `Y` submits `true`, `n` / `N` submits `false`, return on an empty line submits
|
|
642
|
+
* the `default`, ctrl-c cancels; any other key is ignored (stays active).
|
|
643
|
+
*/
|
|
644
|
+
function confirmReduce(state, key) {
|
|
645
|
+
if (key.ctrl && key.name === "c") return {
|
|
646
|
+
state,
|
|
647
|
+
view: confirmView(state),
|
|
648
|
+
status: "cancel"
|
|
649
|
+
};
|
|
650
|
+
let answer;
|
|
651
|
+
const choice = key.name.toLowerCase();
|
|
652
|
+
if (key.name === "return") answer = state.default;
|
|
653
|
+
else if (choice === "y") answer = true;
|
|
654
|
+
else if (choice === "n") answer = false;
|
|
655
|
+
if (answer === void 0) return {
|
|
656
|
+
state,
|
|
657
|
+
view: confirmView(state),
|
|
658
|
+
status: "active"
|
|
659
|
+
};
|
|
660
|
+
return {
|
|
661
|
+
state,
|
|
662
|
+
view: `${submitHeader(state.styler, state.message)} ${state.styler.dim(answer ? "yes" : "no")}`,
|
|
663
|
+
status: "submit",
|
|
664
|
+
value: answer
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
/** Build the initial {@link SelectState} from {@link SelectOptions} — normalizing choices and pre-focusing the default. */
|
|
668
|
+
function createSelectState(options) {
|
|
669
|
+
const choices = options.choices.map(normalizeChoice);
|
|
670
|
+
const index = choices.findIndex((choice) => choice.value === options.default);
|
|
671
|
+
return {
|
|
672
|
+
message: options.message,
|
|
673
|
+
choices,
|
|
674
|
+
styler: options.styler ?? (0, _orkestrel_console.createStyler)(),
|
|
675
|
+
focused: index >= 0 ? index : 0
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
/** Render a {@link SelectState} as a MULTI-LINE styled view — the header followed by one row per choice, the focused row marked. */
|
|
679
|
+
function selectView(state) {
|
|
680
|
+
const lines = state.choices.map((choice, index) => {
|
|
681
|
+
const active = index === state.focused;
|
|
682
|
+
return `${active ? state.styler.cyan(PROMPT_ICONS.pointer) : " "} ${active ? state.styler.green(PROMPT_ICONS.selected) : state.styler.dim(PROMPT_ICONS.dot)} ${active ? state.styler.bold(choice.name) : choice.name}${choice.description === void 0 ? "" : ` ${state.styler.dim(choice.description)}`}`;
|
|
683
|
+
});
|
|
684
|
+
return [promptHeader(state.styler, state.message), ...lines].join("\n");
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Advance a select prompt by one {@link KeyEvent} — the pure `(state, key) → PromptStep<string>`
|
|
688
|
+
* reducer. `up` / `down` (and `k` / `j`) move the focus, WRAPPING at the ends; return submits the
|
|
689
|
+
* focused choice's `value`; ctrl-c cancels. An empty choice list can never submit (a higher layer
|
|
690
|
+
* guards against it); any other key is ignored.
|
|
691
|
+
*/
|
|
692
|
+
function selectReduce(state, key) {
|
|
693
|
+
if (key.ctrl && key.name === "c") return {
|
|
694
|
+
state,
|
|
695
|
+
view: selectView(state),
|
|
696
|
+
status: "cancel"
|
|
697
|
+
};
|
|
698
|
+
const count = state.choices.length;
|
|
699
|
+
if (count === 0) return {
|
|
700
|
+
state,
|
|
701
|
+
view: selectView(state),
|
|
702
|
+
status: "active"
|
|
703
|
+
};
|
|
704
|
+
if (key.name === "up" || key.name === "k") {
|
|
705
|
+
const next = {
|
|
706
|
+
...state,
|
|
707
|
+
focused: (state.focused - 1 + count) % count
|
|
708
|
+
};
|
|
709
|
+
return {
|
|
710
|
+
state: next,
|
|
711
|
+
view: selectView(next),
|
|
712
|
+
status: "active"
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
if (key.name === "down" || key.name === "j") {
|
|
716
|
+
const next = {
|
|
717
|
+
...state,
|
|
718
|
+
focused: (state.focused + 1) % count
|
|
719
|
+
};
|
|
720
|
+
return {
|
|
721
|
+
state: next,
|
|
722
|
+
view: selectView(next),
|
|
723
|
+
status: "active"
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
if (key.name === "return") {
|
|
727
|
+
const choice = state.choices[state.focused];
|
|
728
|
+
const value = choice?.value ?? "";
|
|
729
|
+
return {
|
|
730
|
+
state,
|
|
731
|
+
view: `${submitHeader(state.styler, state.message)} ${state.styler.dim(choice?.name ?? "")}`,
|
|
732
|
+
status: "submit",
|
|
733
|
+
value
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
return {
|
|
737
|
+
state,
|
|
738
|
+
view: selectView(state),
|
|
739
|
+
status: "active"
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
/** Build the initial {@link CheckboxState} from {@link CheckboxOptions} — normalizing choices, seeding the checked set, carrying min/max. */
|
|
743
|
+
function createCheckboxState(options) {
|
|
744
|
+
const choices = options.choices.map(normalizeCheckboxChoice);
|
|
745
|
+
const checked = choices.reduce((indices, choice, index) => {
|
|
746
|
+
if (choice.checked === true) indices.push(index);
|
|
747
|
+
return indices;
|
|
748
|
+
}, []);
|
|
749
|
+
return {
|
|
750
|
+
message: options.message,
|
|
751
|
+
choices,
|
|
752
|
+
styler: options.styler ?? (0, _orkestrel_console.createStyler)(),
|
|
753
|
+
focused: 0,
|
|
754
|
+
checked,
|
|
755
|
+
min: options.min,
|
|
756
|
+
max: options.max
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
/** Render a {@link CheckboxState} as a MULTI-LINE styled view — the header, one box per choice (focused + checked marked), a count, and any error. */
|
|
760
|
+
function checkboxView(state) {
|
|
761
|
+
const lines = state.choices.map((choice, index) => {
|
|
762
|
+
const active = index === state.focused;
|
|
763
|
+
const ticked = state.checked.includes(index);
|
|
764
|
+
return `${active ? state.styler.cyan(PROMPT_ICONS.pointer) : " "} ${ticked ? state.styler.green(PROMPT_ICONS.checked) : state.styler.dim(PROMPT_ICONS.unchecked)} ${active ? state.styler.bold(choice.name) : choice.name}${choice.description === void 0 ? "" : ` ${state.styler.dim(choice.description)}`}`;
|
|
765
|
+
});
|
|
766
|
+
const summary = state.styler.dim(`${state.checked.length} selected`);
|
|
767
|
+
const body = [
|
|
768
|
+
promptHeader(state.styler, state.message),
|
|
769
|
+
...lines,
|
|
770
|
+
summary
|
|
771
|
+
].join("\n");
|
|
772
|
+
return state.error === void 0 ? body : `${body}\n${errorLine(state.styler, state.error)}`;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Advance a checkbox prompt by one {@link KeyEvent} — the pure
|
|
776
|
+
* `(state, key) → PromptStep<readonly string[]>` reducer. `up` / `down` (and `k` / `j`) move the
|
|
777
|
+
* focus (wrapping); `space` toggles the focused index in the checked set; return submits the
|
|
778
|
+
* checked values in CHOICE order — gated by `min` / `max` (an out-of-range count stays active with
|
|
779
|
+
* the reason in the view); ctrl-c cancels.
|
|
780
|
+
*/
|
|
781
|
+
function checkboxReduce(state, key) {
|
|
782
|
+
if (key.ctrl && key.name === "c") return {
|
|
783
|
+
state,
|
|
784
|
+
view: checkboxView(state),
|
|
785
|
+
status: "cancel"
|
|
786
|
+
};
|
|
787
|
+
const count = state.choices.length;
|
|
788
|
+
if ((key.name === "up" || key.name === "k") && count > 0) {
|
|
789
|
+
const next = {
|
|
790
|
+
...state,
|
|
791
|
+
focused: (state.focused - 1 + count) % count,
|
|
792
|
+
error: void 0
|
|
793
|
+
};
|
|
794
|
+
return {
|
|
795
|
+
state: next,
|
|
796
|
+
view: checkboxView(next),
|
|
797
|
+
status: "active"
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
if ((key.name === "down" || key.name === "j") && count > 0) {
|
|
801
|
+
const next = {
|
|
802
|
+
...state,
|
|
803
|
+
focused: (state.focused + 1) % count,
|
|
804
|
+
error: void 0
|
|
805
|
+
};
|
|
806
|
+
return {
|
|
807
|
+
state: next,
|
|
808
|
+
view: checkboxView(next),
|
|
809
|
+
status: "active"
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
if (key.name === "space" && count > 0) {
|
|
813
|
+
const checked = toggleIndex(state.checked, state.focused);
|
|
814
|
+
const next = {
|
|
815
|
+
...state,
|
|
816
|
+
checked,
|
|
817
|
+
error: void 0
|
|
818
|
+
};
|
|
819
|
+
return {
|
|
820
|
+
state: next,
|
|
821
|
+
view: checkboxView(next),
|
|
822
|
+
status: "active"
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
if (key.name === "return") {
|
|
826
|
+
const error = gateSelection(state.checked.length, state.min, state.max);
|
|
827
|
+
if (error !== void 0) {
|
|
828
|
+
const next = {
|
|
829
|
+
...state,
|
|
830
|
+
error
|
|
831
|
+
};
|
|
832
|
+
return {
|
|
833
|
+
state: next,
|
|
834
|
+
view: checkboxView(next),
|
|
835
|
+
status: "active"
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
const ordered = [...state.checked].sort((a, b) => a - b);
|
|
839
|
+
const values = ordered.map((index) => state.choices[index]?.value).filter((value) => value !== void 0);
|
|
840
|
+
const summary = ordered.map((index) => state.choices[index]?.name).filter((name) => name !== void 0).join(", ");
|
|
841
|
+
return {
|
|
842
|
+
state: {
|
|
843
|
+
...state,
|
|
844
|
+
error: void 0
|
|
845
|
+
},
|
|
846
|
+
view: `${submitHeader(state.styler, state.message)} ${state.styler.dim(summary)}`,
|
|
847
|
+
status: "submit",
|
|
848
|
+
value: values
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
return {
|
|
852
|
+
state,
|
|
853
|
+
view: checkboxView(state),
|
|
854
|
+
status: "active"
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
/** Toggle `index` in a readonly index list — copy-on-write, returning the new sorted-by-insertion list. */
|
|
858
|
+
function toggleIndex(indices, index) {
|
|
859
|
+
return indices.includes(index) ? indices.filter((i) => i !== index) : [...indices, index];
|
|
860
|
+
}
|
|
861
|
+
/** The min/max gate for a checkbox submit — the rejection message when `count` is out of range, else `undefined`. */
|
|
862
|
+
function gateSelection(count, min, max) {
|
|
863
|
+
if (min !== void 0 && count < min) return `Select at least ${String(min)} option${min === 1 ? "" : "s"}`;
|
|
864
|
+
if (max !== void 0 && count > max) return `Select no more than ${String(max)} option${max === 1 ? "" : "s"}`;
|
|
865
|
+
}
|
|
866
|
+
/** Build the initial {@link EditorState} from {@link EditorOptions} — resolving the validator + styler, seeding empty lines. */
|
|
867
|
+
function createEditorState(options) {
|
|
868
|
+
return {
|
|
869
|
+
message: options.message,
|
|
870
|
+
default: options.default ?? "",
|
|
871
|
+
validator: resolveValidation(options.validate),
|
|
872
|
+
styler: options.styler ?? (0, _orkestrel_console.createStyler)(),
|
|
873
|
+
lines: [],
|
|
874
|
+
current: ""
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
/** Render an {@link EditorState} as a MULTI-LINE styled view — the header (with a Ctrl+D hint), the committed lines, the in-progress line, and any error. */
|
|
878
|
+
function editorView(state) {
|
|
879
|
+
const view = [`${promptHeader(state.styler, state.message)} ${state.styler.dim("(Ctrl+D to finish)")}`, ...[...state.lines, `${state.styler.cyan(PROMPT_ICONS.pointer)} ${state.current}`]].join("\n");
|
|
880
|
+
return state.error === void 0 ? view : `${view}\n${errorLine(state.styler, state.error)}`;
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Advance an editor prompt by one {@link KeyEvent} — the pure `(state, key) → PromptStep<string>`
|
|
884
|
+
* reducer. Printable characters extend the current line; backspace shrinks it; return commits the
|
|
885
|
+
* current line and starts a fresh one; ctrl-d FINISHES (joining all lines, falling back to the
|
|
886
|
+
* default when empty) through the validator; ctrl-c cancels. An invalid finish stays active with
|
|
887
|
+
* the error.
|
|
888
|
+
*/
|
|
889
|
+
function editorReduce(state, key) {
|
|
890
|
+
if (key.ctrl && key.name === "c") return {
|
|
891
|
+
state,
|
|
892
|
+
view: editorView(state),
|
|
893
|
+
status: "cancel"
|
|
894
|
+
};
|
|
895
|
+
if (key.ctrl && key.name === "d") {
|
|
896
|
+
const lines = state.current.length > 0 ? [...state.lines, state.current] : state.lines;
|
|
897
|
+
const joined = lines.join("\n");
|
|
898
|
+
const answer = joined.length > 0 ? joined : state.default;
|
|
899
|
+
const result = state.validator(answer);
|
|
900
|
+
if (result !== true) {
|
|
901
|
+
const next = {
|
|
902
|
+
...state,
|
|
903
|
+
error: result
|
|
904
|
+
};
|
|
905
|
+
return {
|
|
906
|
+
state: next,
|
|
907
|
+
view: editorView(next),
|
|
908
|
+
status: "active"
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
return {
|
|
912
|
+
state: {
|
|
913
|
+
...state,
|
|
914
|
+
error: void 0
|
|
915
|
+
},
|
|
916
|
+
view: `${submitHeader(state.styler, state.message)} ${state.styler.dim(`${String(lines.length)} line${lines.length === 1 ? "" : "s"}`)}`,
|
|
917
|
+
status: "submit",
|
|
918
|
+
value: answer
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
if (key.name === "return") {
|
|
922
|
+
const next = {
|
|
923
|
+
...state,
|
|
924
|
+
lines: [...state.lines, state.current],
|
|
925
|
+
current: "",
|
|
926
|
+
error: void 0
|
|
927
|
+
};
|
|
928
|
+
return {
|
|
929
|
+
state: next,
|
|
930
|
+
view: editorView(next),
|
|
931
|
+
status: "active"
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
const current = editLine(state.current, key);
|
|
935
|
+
if (current === void 0) return {
|
|
936
|
+
state,
|
|
937
|
+
view: editorView(state),
|
|
938
|
+
status: "active"
|
|
939
|
+
};
|
|
940
|
+
const next = {
|
|
941
|
+
...state,
|
|
942
|
+
current,
|
|
943
|
+
error: void 0
|
|
944
|
+
};
|
|
945
|
+
return {
|
|
946
|
+
state: next,
|
|
947
|
+
view: editorView(next),
|
|
948
|
+
status: "active"
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Apply a single line-editing {@link KeyEvent} to a text buffer — the editing shared by input /
|
|
953
|
+
* password / editor. A printable key appends its character; `backspace` drops the last character;
|
|
954
|
+
* `space` appends a space; ctrl-u clears the line. Returns the new buffer, or `undefined` when the
|
|
955
|
+
* key does not edit the line (so the caller can leave the state untouched).
|
|
956
|
+
*/
|
|
957
|
+
function editLine(value, key) {
|
|
958
|
+
if (key.ctrl && key.name === "u") return "";
|
|
959
|
+
if (key.name === "backspace") return value.slice(0, -1);
|
|
960
|
+
if (key.name === "space") return `${value} `;
|
|
961
|
+
if (!key.ctrl && !key.meta && [...key.name].length === 1 && isPrintable(key.name)) return `${value}${key.sequence}`;
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Produce the WIRE-SAFE form of a prompt's options — the {@link PendingPrompt.options} a broker
|
|
965
|
+
* serializes over SSE. Drops everything that cannot cross the wire (a `styler`, and any
|
|
966
|
+
* function-valued `validate` rule), while KEEPING the declarative data a remote client needs to
|
|
967
|
+
* reconstruct the prompt: the {@link ValidationRules} as data, plus `message` / `choices` /
|
|
968
|
+
* `default` / `mask` / `min` / `max`. This is WHY T-a's validation is rules-as-data: a `Validator`
|
|
969
|
+
* FUNCTION can't be serialized, but its declarative rules can — the client rebuilds the validator
|
|
970
|
+
* from them via {@link resolveValidation}.
|
|
971
|
+
*
|
|
972
|
+
* @remarks
|
|
973
|
+
* - **Functions are dropped.** A top-level function value (e.g. a bare-`Validator` `validate`) is
|
|
974
|
+
* omitted entirely; the `styler` (a fluent function-bearing object) is dropped by key.
|
|
975
|
+
* - **`validate` rules are flattened.** A {@link ValidationRules} bag is copied with each
|
|
976
|
+
* function rule replaced by `true` (the rule's INTENT survives as the built-in check; its
|
|
977
|
+
* custom function does not). A bare-function `validate` is dropped (no declarative form to keep).
|
|
978
|
+
* - **`choices` are function-stripped.** Each choice keeps its plain fields (`name` / `value` /
|
|
979
|
+
* `description` / `checked`); a bare string passes through.
|
|
980
|
+
*
|
|
981
|
+
* @param options - The raw prompt options bag (may hold functions / a styler)
|
|
982
|
+
* @returns A JSON-safe options record — only serializable, declarative data
|
|
983
|
+
*/
|
|
984
|
+
function serializePromptOptions(options) {
|
|
985
|
+
const result = {};
|
|
986
|
+
for (const [key, value] of Object.entries(options)) {
|
|
987
|
+
if (key === "styler" || typeof value === "function") continue;
|
|
988
|
+
if (key === "validate") {
|
|
989
|
+
const rules = serializeValidationRules(value);
|
|
990
|
+
if (rules !== void 0) result[key] = rules;
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
if (key === "choices") {
|
|
994
|
+
result[key] = serializeChoices(value);
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
result[key] = value;
|
|
998
|
+
}
|
|
999
|
+
return result;
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Flatten a `validate` option to its wire-safe {@link ValidationRules} DATA — a function rule
|
|
1003
|
+
* becomes `true` (its built-in check survives; its body cannot cross the wire). A bare-function
|
|
1004
|
+
* `validate` (no rules object) has no declarative form, so it yields `undefined` (dropped).
|
|
1005
|
+
*/
|
|
1006
|
+
function serializeValidationRules(validate) {
|
|
1007
|
+
if (!(0, _orkestrel_contract.isRecord)(validate)) return void 0;
|
|
1008
|
+
const rules = {};
|
|
1009
|
+
for (const [rule, value] of Object.entries(validate)) rules[rule] = typeof value === "function" ? true : value;
|
|
1010
|
+
return rules;
|
|
1011
|
+
}
|
|
1012
|
+
/** Strip functions from a `choices` option — each choice keeps its plain fields; a bare string passes through. */
|
|
1013
|
+
function serializeChoices(choices) {
|
|
1014
|
+
if (!Array.isArray(choices)) return [];
|
|
1015
|
+
return choices.map((choice) => {
|
|
1016
|
+
if ((0, _orkestrel_contract.isString)(choice)) return choice;
|
|
1017
|
+
if (!(0, _orkestrel_contract.isRecord)(choice)) return choice;
|
|
1018
|
+
const normalized = {};
|
|
1019
|
+
for (const [key, value] of Object.entries(choice)) if (typeof value !== "function") normalized[key] = value;
|
|
1020
|
+
return normalized;
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
/** Narrow an unknown value to a {@link PromptType} — one of the six prompt forms. */
|
|
1024
|
+
var isPromptType = (0, _orkestrel_contract.literalOf)("input", "password", "confirm", "select", "checkbox", "editor");
|
|
1025
|
+
/** Narrow an unknown value to a {@link PendingPromptStatus}. */
|
|
1026
|
+
var isPendingPromptStatus = (0, _orkestrel_contract.literalOf)("pending", "answered", "expired");
|
|
1027
|
+
/**
|
|
1028
|
+
* Narrow an unknown wire value to a {@link PendingPrompt} — the §14 guard a {@link PromptClient}
|
|
1029
|
+
* applies to each decoded SSE `pending` payload before dispatching it (never an `as`).
|
|
1030
|
+
*/
|
|
1031
|
+
var isPendingPrompt = (0, _orkestrel_contract.recordOf)({
|
|
1032
|
+
id: _orkestrel_contract.isNonEmptyString,
|
|
1033
|
+
form: isPromptType,
|
|
1034
|
+
message: _orkestrel_contract.isString,
|
|
1035
|
+
options: _orkestrel_contract.isRecord,
|
|
1036
|
+
status: isPendingPromptStatus,
|
|
1037
|
+
time: _orkestrel_contract.isNumber
|
|
1038
|
+
});
|
|
1039
|
+
/**
|
|
1040
|
+
* Rebuild a wire-decoded `validate` payload into a {@link ValidationRules} bag — the inverse of
|
|
1041
|
+
* {@link serializeValidationRules}. Keeps only the primitive rule values (`boolean` / `number` /
|
|
1042
|
+
* `string`) a serialized prompt could carry; an empty / non-record / all-dropped payload yields
|
|
1043
|
+
* `undefined` (no rules to apply). The client feeds the result back through {@link resolveValidation}.
|
|
1044
|
+
*/
|
|
1045
|
+
function reconstructValidationRules(value) {
|
|
1046
|
+
if (!(0, _orkestrel_contract.isRecord)(value)) return void 0;
|
|
1047
|
+
const rules = {};
|
|
1048
|
+
let count = 0;
|
|
1049
|
+
for (const [rule, item] of Object.entries(value)) {
|
|
1050
|
+
if (rule === "pattern") continue;
|
|
1051
|
+
if ((0, _orkestrel_contract.isBoolean)(item) || (0, _orkestrel_contract.isNumber)(item) || (0, _orkestrel_contract.isString)(item)) {
|
|
1052
|
+
rules[rule] = item;
|
|
1053
|
+
count += 1;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
if (count === 0) return void 0;
|
|
1057
|
+
return rules;
|
|
1058
|
+
}
|
|
1059
|
+
/** Read one option by key, narrowed by `guard` — `undefined` when absent or off-shape (§14, never an `as`). */
|
|
1060
|
+
function resolveOption(options, key, guard) {
|
|
1061
|
+
const value = options[key];
|
|
1062
|
+
return guard(value) ? value : void 0;
|
|
1063
|
+
}
|
|
1064
|
+
/** Narrow an unknown value to a {@link PromptChoice} — the `recordOf` shape inlined so no non-exported member lingers (§5). */
|
|
1065
|
+
function isPromptChoice(value) {
|
|
1066
|
+
return (0, _orkestrel_contract.recordOf)({
|
|
1067
|
+
name: _orkestrel_contract.isString,
|
|
1068
|
+
value: _orkestrel_contract.isString,
|
|
1069
|
+
description: _orkestrel_contract.isString
|
|
1070
|
+
}, ["description"])(value);
|
|
1071
|
+
}
|
|
1072
|
+
/** Narrow an unknown value to a {@link CheckboxChoice} — the `recordOf` shape inlined so no non-exported member lingers (§5). */
|
|
1073
|
+
function isCheckboxChoice(value) {
|
|
1074
|
+
return (0, _orkestrel_contract.recordOf)({
|
|
1075
|
+
name: _orkestrel_contract.isString,
|
|
1076
|
+
value: _orkestrel_contract.isString,
|
|
1077
|
+
description: _orkestrel_contract.isString,
|
|
1078
|
+
checked: _orkestrel_contract.isBoolean
|
|
1079
|
+
}, ["description", "checked"])(value);
|
|
1080
|
+
}
|
|
1081
|
+
/** Read a `choices` option as a list of bare strings / full choices — each element narrowed by `guard`, off-shape elements stringified. */
|
|
1082
|
+
function resolveChoices(options, guard) {
|
|
1083
|
+
const choices = options.choices;
|
|
1084
|
+
if (!Array.isArray(choices)) return [];
|
|
1085
|
+
return choices.map((choice) => {
|
|
1086
|
+
if ((0, _orkestrel_contract.isString)(choice)) return choice;
|
|
1087
|
+
if (guard(choice)) return choice;
|
|
1088
|
+
return String(choice);
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Sanitize a list of resolved choices' human-readable labels (`name` + `description`) with
|
|
1093
|
+
* {@link stripControls} — shared by the `select` and `checkbox` branches of
|
|
1094
|
+
* {@link dispatchPendingPrompt} so a remote-supplied choice can never inject raw control bytes
|
|
1095
|
+
* into the local terminal's rendered view.
|
|
1096
|
+
*
|
|
1097
|
+
* @param choices - The resolved choices (bare strings or full {@link PromptChoice} /
|
|
1098
|
+
* {@link CheckboxChoice} objects) as returned by {@link resolveChoices}
|
|
1099
|
+
* @returns The same choices with every `name` / `description` control-stripped
|
|
1100
|
+
*/
|
|
1101
|
+
function sanitizeChoiceLabels(choices) {
|
|
1102
|
+
return choices.map((choice) => {
|
|
1103
|
+
if ((0, _orkestrel_contract.isString)(choice)) return (0, _orkestrel_console.stripControls)(choice);
|
|
1104
|
+
const description = choice.description === void 0 ? void 0 : (0, _orkestrel_console.stripControls)(choice.description);
|
|
1105
|
+
return {
|
|
1106
|
+
...choice,
|
|
1107
|
+
name: (0, _orkestrel_console.stripControls)(choice.name),
|
|
1108
|
+
description
|
|
1109
|
+
};
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Dispatch a {@link PendingPrompt} to the matching {@link PromptFormInterface} method — the bridge
|
|
1114
|
+
* step a {@link PromptClient} runs to drive a LOCAL terminal with a prompt issued elsewhere.
|
|
1115
|
+
* Reconstructs typed options from the wire-safe {@link PendingPrompt.options} (every field
|
|
1116
|
+
* §14-narrowed, never an `as`; the validator rebuilt from rules via {@link reconstructValidationRules}),
|
|
1117
|
+
* then calls the matching prompt form and returns its resolved value.
|
|
1118
|
+
*
|
|
1119
|
+
* @param terminal - The local {@link PromptFormInterface} to drive
|
|
1120
|
+
* @param pending - The decoded pending prompt to dispatch
|
|
1121
|
+
* @returns The prompt's resolved value (a `string` / `boolean` / `readonly string[]` per form)
|
|
1122
|
+
*/
|
|
1123
|
+
function dispatchPendingPrompt(terminal, pending) {
|
|
1124
|
+
const options = pending.options;
|
|
1125
|
+
const validate = reconstructValidationRules(options.validate);
|
|
1126
|
+
const message = (0, _orkestrel_console.stripControls)(pending.message);
|
|
1127
|
+
switch (pending.form) {
|
|
1128
|
+
case "input": {
|
|
1129
|
+
const value = resolveOption(options, "default", _orkestrel_contract.isString);
|
|
1130
|
+
return terminal.input({
|
|
1131
|
+
message,
|
|
1132
|
+
default: value === void 0 ? void 0 : (0, _orkestrel_console.stripControls)(value),
|
|
1133
|
+
validate
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
case "password": {
|
|
1137
|
+
const value = resolveOption(options, "mask", _orkestrel_contract.isString);
|
|
1138
|
+
return terminal.password({
|
|
1139
|
+
message,
|
|
1140
|
+
mask: value === void 0 ? void 0 : (0, _orkestrel_console.stripControls)(value),
|
|
1141
|
+
validate
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
case "confirm": return terminal.confirm({
|
|
1145
|
+
message,
|
|
1146
|
+
default: resolveOption(options, "default", _orkestrel_contract.isBoolean)
|
|
1147
|
+
});
|
|
1148
|
+
case "select": {
|
|
1149
|
+
const value = resolveOption(options, "default", _orkestrel_contract.isString);
|
|
1150
|
+
return terminal.select({
|
|
1151
|
+
message,
|
|
1152
|
+
choices: sanitizeChoiceLabels(resolveChoices(options, isPromptChoice)),
|
|
1153
|
+
default: value === void 0 ? void 0 : (0, _orkestrel_console.stripControls)(value)
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
case "checkbox": return terminal.checkbox({
|
|
1157
|
+
message,
|
|
1158
|
+
choices: sanitizeChoiceLabels(resolveChoices(options, isCheckboxChoice)),
|
|
1159
|
+
min: resolveOption(options, "min", _orkestrel_contract.isNumber),
|
|
1160
|
+
max: resolveOption(options, "max", _orkestrel_contract.isNumber)
|
|
1161
|
+
});
|
|
1162
|
+
case "editor": {
|
|
1163
|
+
const value = resolveOption(options, "default", _orkestrel_contract.isString);
|
|
1164
|
+
return terminal.editor({
|
|
1165
|
+
message,
|
|
1166
|
+
default: value === void 0 ? void 0 : (0, _orkestrel_console.stripControls)(value),
|
|
1167
|
+
validate
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* The default {@link import('./types.js').TimerHandler} — a thin host `setTimeout` / `clearTimeout`
|
|
1174
|
+
* wrapper that arms `callback` after `ms` and returns a {@link TimerCancel}. The deadline seam
|
|
1175
|
+
* behind both the {@link import('./Prompt.js').Prompt} broker (its expiry) and the
|
|
1176
|
+
* {@link import('./PromptClient.js').PromptClient} (its reconnect backoff); a test injects a
|
|
1177
|
+
* deterministic timer instead, so neither entity touches real time.
|
|
1178
|
+
*/
|
|
1179
|
+
function defaultTimer(callback, ms) {
|
|
1180
|
+
const handle = setTimeout(callback, ms);
|
|
1181
|
+
return () => clearTimeout(handle);
|
|
1182
|
+
}
|
|
1183
|
+
/** The default {@link import('./types.js').FetchHandler} — the global `fetch`, adapted to the minimal injected shape the {@link import('./PromptClient.js').PromptClient} uses. */
|
|
1184
|
+
function globalFetch(input, init) {
|
|
1185
|
+
return fetch(input, init);
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Whether a caught value is an `AbortError` — the {@link import('./PromptClient.js').PromptClient}
|
|
1189
|
+
* distinguishes a deliberate `disconnect` / teardown (an aborted `fetch`) from a real fault, so it
|
|
1190
|
+
* exits its connect loop quietly instead of emitting `error` / reconnecting.
|
|
1191
|
+
*/
|
|
1192
|
+
function isAbortError(error) {
|
|
1193
|
+
return (error instanceof DOMException || error instanceof Error) && error.name === "AbortError";
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* Parse a JSON wire string TOTAL — a malformed / empty payload yields `undefined` (the caller's
|
|
1197
|
+
* guard then rejects it), never a throw. The {@link import('./PromptClient.js').PromptClient}
|
|
1198
|
+
* decodes every SSE `data` field through this before §14-narrowing it.
|
|
1199
|
+
*/
|
|
1200
|
+
function parseWireJSON(text) {
|
|
1201
|
+
if (text.length === 0) return void 0;
|
|
1202
|
+
try {
|
|
1203
|
+
return JSON.parse(text);
|
|
1204
|
+
} catch {
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Whether `url` is an INSECURE remote endpoint — a plain `http://` URL whose host is NOT a
|
|
1210
|
+
* loopback address. Pure string parsing (no `URL` global), so it stays total on malformed input.
|
|
1211
|
+
*
|
|
1212
|
+
* @remarks
|
|
1213
|
+
* A loopback host (`localhost`, `127.0.0.1`, `[::1]`) over `http://` is exempt (local
|
|
1214
|
+
* development has no network hop to eavesdrop on); every other `http://` host is insecure.
|
|
1215
|
+
* An `https://` URL (or any non-`http://` scheme) is never flagged.
|
|
1216
|
+
*
|
|
1217
|
+
* @param url - The candidate endpoint URL
|
|
1218
|
+
* @returns `true` when `url` is a non-loopback `http://` endpoint
|
|
1219
|
+
*
|
|
1220
|
+
* @example
|
|
1221
|
+
* ```ts
|
|
1222
|
+
* isInsecureRemote('http://example.com') // true
|
|
1223
|
+
* isInsecureRemote('http://localhost:3000') // false
|
|
1224
|
+
* isInsecureRemote('https://example.com') // false
|
|
1225
|
+
* ```
|
|
1226
|
+
*/
|
|
1227
|
+
function isInsecureRemote(url) {
|
|
1228
|
+
if (!url.startsWith("http://")) return false;
|
|
1229
|
+
const rest = url.slice(7);
|
|
1230
|
+
const hostEnd = rest.search(/[/?#]/);
|
|
1231
|
+
const authority = hostEnd === -1 ? rest : rest.slice(0, hostEnd);
|
|
1232
|
+
const host = authority.includes("@") ? authority.slice(authority.indexOf("@") + 1) : authority;
|
|
1233
|
+
const hostname = host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : host.split(":")[0] ?? "";
|
|
1234
|
+
return hostname !== "localhost" && hostname !== "127.0.0.1" && hostname !== "[::1]";
|
|
1235
|
+
}
|
|
1236
|
+
//#endregion
|
|
1237
|
+
//#region src/core/Prompt.ts
|
|
1238
|
+
/**
|
|
1239
|
+
* The headless prompt BROKER (observable §13) — parks each {@link PromptInterface} call as a
|
|
1240
|
+
* pending prompt and returns a Promise that resolves when the prompt is {@link answer}ed (or
|
|
1241
|
+
* rejects on timeout / teardown). The tri-surface's headless arm: there is no terminal here, so a
|
|
1242
|
+
* transport forwards each `pending` event to whoever can answer, and {@link answer} resolves the
|
|
1243
|
+
* parked Promise — for environments where direct user access is unavailable (a headless server on
|
|
1244
|
+
* stdio, a browser with no TTY, a prompt issued on one machine answered on another).
|
|
1245
|
+
*
|
|
1246
|
+
* @remarks
|
|
1247
|
+
* - **Park-as-Promise.** Each `input` / `password` / `confirm` / `select` / `checkbox` / `editor`
|
|
1248
|
+
* mints an id (`crypto.randomUUID()`), parks a wire-safe {@link PendingPrompt}, emits `pending`,
|
|
1249
|
+
* and returns an unresolved Promise. The prompt's options are serialized
|
|
1250
|
+
* ({@link serializePromptOptions}) so a transport can forward them as-is.
|
|
1251
|
+
* - **Answer validates + type-checks.** {@link answer} runs the prompt's per-form gate: it
|
|
1252
|
+
* type-checks `value` to the form (string / boolean / string[]) AND, for the text forms, runs
|
|
1253
|
+
* the validator resolved from the original `validate` rules. A rejected answer returns `false`
|
|
1254
|
+
* and the prompt stays `pending`; an accepted answer resolves the Promise, emits `answer`, and
|
|
1255
|
+
* removes the prompt.
|
|
1256
|
+
* - **Timeout → expire → reject.** An unanswered prompt expires after `timeout` ms (via the
|
|
1257
|
+
* INJECTED timer): `expire` fires and the parked Promise rejects with a {@link TerminalError}
|
|
1258
|
+
* (`code: 'EXPIRE'`). {@link destroy} expires every still-pending prompt the same way.
|
|
1259
|
+
* - **Deterministic.** The timer is injectable ({@link import('./types.js').TimerHandler}); a test
|
|
1260
|
+
* supplies one that fires on demand, so expiry is driven without real time.
|
|
1261
|
+
*
|
|
1262
|
+
* @example
|
|
1263
|
+
* ```ts
|
|
1264
|
+
* const prompt = createPrompt({ timeout: 60_000 })
|
|
1265
|
+
* prompt.emitter.on('pending', (pending) => broadcast(pending)) // forward to a client
|
|
1266
|
+
*
|
|
1267
|
+
* const name = await prompt.input({ message: 'Your name' }) // parks; resolves on answer()
|
|
1268
|
+
* // ...elsewhere, a client POSTs the answer back:
|
|
1269
|
+
* prompt.answer(id, 'Ada') // resolves the awaited input() above
|
|
1270
|
+
* ```
|
|
1271
|
+
*/
|
|
1272
|
+
var Prompt = class {
|
|
1273
|
+
#timeout;
|
|
1274
|
+
#timer;
|
|
1275
|
+
#parked = /* @__PURE__ */ new Map();
|
|
1276
|
+
#emitter;
|
|
1277
|
+
#destroyed = false;
|
|
1278
|
+
constructor(options) {
|
|
1279
|
+
this.#timeout = options?.timeout ?? 3e5;
|
|
1280
|
+
this.#timer = options?.timer ?? defaultTimer;
|
|
1281
|
+
this.#emitter = new _orkestrel_emitter.Emitter({
|
|
1282
|
+
on: options?.on,
|
|
1283
|
+
error: options?.error
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
get emitter() {
|
|
1287
|
+
return this.#emitter;
|
|
1288
|
+
}
|
|
1289
|
+
get count() {
|
|
1290
|
+
return this.#parked.size;
|
|
1291
|
+
}
|
|
1292
|
+
pending(id) {
|
|
1293
|
+
if (id !== void 0) return this.#parked.get(id)?.prompt;
|
|
1294
|
+
const result = [];
|
|
1295
|
+
for (const parked of this.#parked.values()) result.push(parked.prompt);
|
|
1296
|
+
return result;
|
|
1297
|
+
}
|
|
1298
|
+
answer(id, value) {
|
|
1299
|
+
const parked = this.#parked.get(id);
|
|
1300
|
+
if (parked === void 0) return false;
|
|
1301
|
+
const accepted = parked.respond(value);
|
|
1302
|
+
if (accepted === void 0) return false;
|
|
1303
|
+
parked.cancel();
|
|
1304
|
+
this.#emitter.emit("answer", id, accepted);
|
|
1305
|
+
this.#parked.delete(id);
|
|
1306
|
+
return true;
|
|
1307
|
+
}
|
|
1308
|
+
input(options) {
|
|
1309
|
+
const validator = resolveValidation(options.validate);
|
|
1310
|
+
return this.#park("input", options.message, options, (value) => (0, _orkestrel_contract.isString)(value) && validator(value) === true ? value : void 0);
|
|
1311
|
+
}
|
|
1312
|
+
password(options) {
|
|
1313
|
+
const validator = resolveValidation(options.validate);
|
|
1314
|
+
return this.#park("password", options.message, options, (value) => (0, _orkestrel_contract.isString)(value) && validator(value) === true ? value : void 0);
|
|
1315
|
+
}
|
|
1316
|
+
confirm(options) {
|
|
1317
|
+
return this.#park("confirm", options.message, options, (value) => (0, _orkestrel_contract.isBoolean)(value) ? value : void 0);
|
|
1318
|
+
}
|
|
1319
|
+
select(options) {
|
|
1320
|
+
const values = new Set(options.choices.map(normalizeChoice).map((choice) => choice.value));
|
|
1321
|
+
return this.#park("select", options.message, options, (value) => (0, _orkestrel_contract.isString)(value) && values.has(value) ? value : void 0);
|
|
1322
|
+
}
|
|
1323
|
+
checkbox(options) {
|
|
1324
|
+
const values = new Set(options.choices.map(normalizeCheckboxChoice).map((choice) => choice.value));
|
|
1325
|
+
const { min, max } = options;
|
|
1326
|
+
return this.#park("checkbox", options.message, options, (value) => {
|
|
1327
|
+
if (!(0, _orkestrel_contract.isArray)(value) || !value.every(_orkestrel_contract.isString)) return void 0;
|
|
1328
|
+
if (!value.every((item) => values.has(item))) return void 0;
|
|
1329
|
+
if (min !== void 0 && value.length < min) return void 0;
|
|
1330
|
+
if (max !== void 0 && value.length > max) return void 0;
|
|
1331
|
+
return value;
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
editor(options) {
|
|
1335
|
+
const validator = resolveValidation(options.validate);
|
|
1336
|
+
return this.#park("editor", options.message, options, (value) => (0, _orkestrel_contract.isString)(value) && validator(value) === true ? value : void 0);
|
|
1337
|
+
}
|
|
1338
|
+
destroy() {
|
|
1339
|
+
if (this.#destroyed) return;
|
|
1340
|
+
this.#destroyed = true;
|
|
1341
|
+
for (const id of [...this.#parked.keys()]) this.#expire(id);
|
|
1342
|
+
this.#parked.clear();
|
|
1343
|
+
this.#emitter.destroy();
|
|
1344
|
+
}
|
|
1345
|
+
#park(form, message, options, gate) {
|
|
1346
|
+
if (this.#destroyed) return Promise.reject(new TerminalError("EXPIRE", "broker destroyed"));
|
|
1347
|
+
const id = crypto.randomUUID();
|
|
1348
|
+
const prompt = {
|
|
1349
|
+
id,
|
|
1350
|
+
form,
|
|
1351
|
+
message,
|
|
1352
|
+
options: serializePromptOptions(options),
|
|
1353
|
+
status: "pending",
|
|
1354
|
+
time: Date.now()
|
|
1355
|
+
};
|
|
1356
|
+
return new Promise((resolve, reject) => {
|
|
1357
|
+
const cancel = this.#timer(() => this.#expire(id), this.#timeout);
|
|
1358
|
+
const respond = (value) => {
|
|
1359
|
+
const accepted = gate(value);
|
|
1360
|
+
if (accepted === void 0) return void 0;
|
|
1361
|
+
const current = this.#parked.get(id);
|
|
1362
|
+
if (current !== void 0) this.#parked.set(id, {
|
|
1363
|
+
...current,
|
|
1364
|
+
prompt: {
|
|
1365
|
+
...current.prompt,
|
|
1366
|
+
status: "answered"
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
resolve(accepted);
|
|
1370
|
+
return accepted;
|
|
1371
|
+
};
|
|
1372
|
+
const expire = () => {
|
|
1373
|
+
reject(new TerminalError("EXPIRE", `prompt ${id} expired`, { id }));
|
|
1374
|
+
};
|
|
1375
|
+
this.#parked.set(id, {
|
|
1376
|
+
prompt,
|
|
1377
|
+
respond,
|
|
1378
|
+
expire,
|
|
1379
|
+
cancel
|
|
1380
|
+
});
|
|
1381
|
+
this.#emitter.emit("pending", prompt);
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
#expire(id) {
|
|
1385
|
+
const parked = this.#parked.get(id);
|
|
1386
|
+
if (parked === void 0) return;
|
|
1387
|
+
parked.cancel();
|
|
1388
|
+
this.#parked.set(id, {
|
|
1389
|
+
...parked,
|
|
1390
|
+
prompt: {
|
|
1391
|
+
...parked.prompt,
|
|
1392
|
+
status: "expired"
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
this.#emitter.emit("expire", id);
|
|
1396
|
+
this.#parked.delete(id);
|
|
1397
|
+
parked.expire();
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
1400
|
+
//#endregion
|
|
1401
|
+
//#region src/core/PromptClient.ts
|
|
1402
|
+
/**
|
|
1403
|
+
* The SSE prompt BRIDGE (observable §13) — the client-side counterpart to
|
|
1404
|
+
* {@link import('./Prompt.js').Prompt}. Connects to a remote broker's SSE endpoint, receives
|
|
1405
|
+
* serialized pending prompts, dispatches EACH to a LOCAL {@link PromptFormInterface} terminal (so
|
|
1406
|
+
* a human at THIS machine answers a prompt parked elsewhere), and POSTs the answer back.
|
|
1407
|
+
* Universal — `fetch` + SSE are web-standard, so it runs in a browser or on a server; the
|
|
1408
|
+
* injected `fetch` / timer make it fully deterministic in tests.
|
|
1409
|
+
*
|
|
1410
|
+
* @remarks
|
|
1411
|
+
* - **Connect + reconnect.** {@link connect} opens the SSE stream (via the injected `fetch` + the
|
|
1412
|
+
* core `SSEParser`) and loops, reconnecting after the stream drops with the `delay` backoff
|
|
1413
|
+
* (driven by the injected timer) — unless `reconnect` is `false`, the client was
|
|
1414
|
+
* {@link destroy}ed, or the drop was a deliberate {@link disconnect} (an abort).
|
|
1415
|
+
* - **Dispatch + answer.** Each decoded `pending` event is narrowed to a `PendingPrompt` (§14 —
|
|
1416
|
+
* never an `as`), dispatched to `terminal`, and its resolved value POSTed back to `url`.
|
|
1417
|
+
* Dispatch is strictly SERIAL: the read loop drives the terminal for ONE prompt at a time and
|
|
1418
|
+
* only reads/dispatches the next event after the current prompt fully settles (its answer
|
|
1419
|
+
* POSTed). A prompt id redelivered by the broker AFTER its prior dispatch has settled is
|
|
1420
|
+
* dispatched again — the client does not dedupe across completion.
|
|
1421
|
+
* - **Server signals.** An `expire` event (the broker dropped a parked prompt) emits `expire`; a
|
|
1422
|
+
* `shutdown` event calls {@link disconnect} (not {@link destroy}) — the client stops streaming
|
|
1423
|
+
* without auto-reconnect but stays reusable; a later {@link connect} recovers it.
|
|
1424
|
+
* - **Lean events (§13).** `connect` / `disconnect` / `expire` / `error` — errors are `unknown`.
|
|
1425
|
+
* `disconnect` fires exactly once per connected-to-disconnected transition, whether triggered by
|
|
1426
|
+
* the server ending the stream cleanly or by a deliberate {@link disconnect} / {@link destroy}.
|
|
1427
|
+
*
|
|
1428
|
+
* @example
|
|
1429
|
+
* ```ts
|
|
1430
|
+
* const client = createPromptClient({
|
|
1431
|
+
* url: 'http://localhost:3001/prompts',
|
|
1432
|
+
* terminal: createTerminal(), // a local PromptFormInterface (T-c)
|
|
1433
|
+
* on: { connect: () => log('connected') },
|
|
1434
|
+
* })
|
|
1435
|
+
* await client.connect() // streams remote prompts to the local terminal, POSTs answers back
|
|
1436
|
+
* ```
|
|
1437
|
+
*/
|
|
1438
|
+
var PromptClient = class {
|
|
1439
|
+
url;
|
|
1440
|
+
#terminal;
|
|
1441
|
+
#token;
|
|
1442
|
+
#reconnect;
|
|
1443
|
+
#delay;
|
|
1444
|
+
#fetch;
|
|
1445
|
+
#timer;
|
|
1446
|
+
#emitter;
|
|
1447
|
+
#controller;
|
|
1448
|
+
#backoff;
|
|
1449
|
+
#wake;
|
|
1450
|
+
#connecting = false;
|
|
1451
|
+
#connected = false;
|
|
1452
|
+
#destroyed = false;
|
|
1453
|
+
#warnedInsecureToken = false;
|
|
1454
|
+
constructor(options) {
|
|
1455
|
+
this.url = options.url;
|
|
1456
|
+
this.#terminal = options.terminal;
|
|
1457
|
+
this.#token = options.token;
|
|
1458
|
+
this.#reconnect = options.reconnect ?? true;
|
|
1459
|
+
this.#delay = options.delay ?? 2e3;
|
|
1460
|
+
this.#fetch = options.fetch ?? globalFetch;
|
|
1461
|
+
this.#timer = options.timer ?? defaultTimer;
|
|
1462
|
+
this.#emitter = new _orkestrel_emitter.Emitter({
|
|
1463
|
+
on: options.on,
|
|
1464
|
+
error: options.error
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
get emitter() {
|
|
1468
|
+
return this.#emitter;
|
|
1469
|
+
}
|
|
1470
|
+
get connected() {
|
|
1471
|
+
return this.#connected;
|
|
1472
|
+
}
|
|
1473
|
+
async connect() {
|
|
1474
|
+
if (this.#destroyed) return;
|
|
1475
|
+
if (this.#connecting) return;
|
|
1476
|
+
this.#connecting = true;
|
|
1477
|
+
while (this.#connecting && !this.#destroyed) {
|
|
1478
|
+
try {
|
|
1479
|
+
await this.#stream();
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
this.#markDisconnected();
|
|
1482
|
+
if (this.#destroyed || isAbortError(error)) return;
|
|
1483
|
+
this.#emitter.emit("error", error);
|
|
1484
|
+
}
|
|
1485
|
+
if (!this.#reconnect || !this.#connecting || this.#destroyed) return;
|
|
1486
|
+
await this.#wait(this.#delay);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
disconnect() {
|
|
1490
|
+
this.#connecting = false;
|
|
1491
|
+
this.#controller?.abort();
|
|
1492
|
+
this.#controller = void 0;
|
|
1493
|
+
this.#backoff?.();
|
|
1494
|
+
this.#backoff = void 0;
|
|
1495
|
+
const wake = this.#wake;
|
|
1496
|
+
this.#wake = void 0;
|
|
1497
|
+
wake?.();
|
|
1498
|
+
this.#markDisconnected();
|
|
1499
|
+
}
|
|
1500
|
+
destroy() {
|
|
1501
|
+
if (this.#destroyed) return;
|
|
1502
|
+
this.#destroyed = true;
|
|
1503
|
+
this.disconnect();
|
|
1504
|
+
this.#emitter.destroy();
|
|
1505
|
+
}
|
|
1506
|
+
async #stream() {
|
|
1507
|
+
if (this.#token !== void 0 && isInsecureRemote(this.url) && !this.#warnedInsecureToken) {
|
|
1508
|
+
this.#warnedInsecureToken = true;
|
|
1509
|
+
this.#emitter.emit("error", /* @__PURE__ */ new Error("auth token sent as cleartext over insecure http; use https"));
|
|
1510
|
+
}
|
|
1511
|
+
const controller = new AbortController();
|
|
1512
|
+
this.#controller = controller;
|
|
1513
|
+
const response = await this.#fetch(this.url, {
|
|
1514
|
+
headers: this.#headers({ Accept: ACCEPT_EVENT_STREAM }),
|
|
1515
|
+
signal: controller.signal
|
|
1516
|
+
});
|
|
1517
|
+
if (!response.ok) throw new Error(`broker returned ${String(response.status)}`);
|
|
1518
|
+
const body = response.body;
|
|
1519
|
+
if (body === null) throw new Error("broker sent no stream");
|
|
1520
|
+
this.#connected = true;
|
|
1521
|
+
this.#emitter.emit("connect");
|
|
1522
|
+
const reader = body.getReader();
|
|
1523
|
+
const decoder = new TextDecoder();
|
|
1524
|
+
const parser = (0, _orkestrel_sse.createSSEParser)({ limit: SSE_BUFFER_LIMIT });
|
|
1525
|
+
try {
|
|
1526
|
+
for (;;) {
|
|
1527
|
+
const { done, value } = await reader.read();
|
|
1528
|
+
if (done) break;
|
|
1529
|
+
for (const event of parser.parse(decoder.decode(value, { stream: true }))) await this.#handle(event);
|
|
1530
|
+
}
|
|
1531
|
+
} finally {
|
|
1532
|
+
reader.releaseLock();
|
|
1533
|
+
}
|
|
1534
|
+
this.#markDisconnected();
|
|
1535
|
+
}
|
|
1536
|
+
async #handle(event) {
|
|
1537
|
+
if (event.event === SSE_EVENTS.pending) {
|
|
1538
|
+
const parsed = parseWireJSON(event.data);
|
|
1539
|
+
if (isPendingPrompt(parsed)) await this.#dispatch(parsed.id, parsed);
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
if (event.event === SSE_EVENTS.expire) {
|
|
1543
|
+
const parsed = parseWireJSON(event.data);
|
|
1544
|
+
if ((0, _orkestrel_contract.isRecord)(parsed) && (0, _orkestrel_contract.isString)(parsed.id)) this.#emitter.emit("expire", parsed.id);
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
if (event.event === SSE_EVENTS.shutdown) this.disconnect();
|
|
1548
|
+
}
|
|
1549
|
+
async #dispatch(id, pending) {
|
|
1550
|
+
try {
|
|
1551
|
+
const value = await dispatchPendingPrompt(this.#terminal, pending);
|
|
1552
|
+
await this.#post(id, value);
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
this.#emitter.emit("error", error);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
async #post(id, value) {
|
|
1558
|
+
if (!(await this.#fetch(this.url, {
|
|
1559
|
+
method: "POST",
|
|
1560
|
+
headers: this.#headers({ "Content-Type": "application/json" }),
|
|
1561
|
+
body: JSON.stringify({
|
|
1562
|
+
id,
|
|
1563
|
+
value
|
|
1564
|
+
})
|
|
1565
|
+
})).ok) this.#emitter.emit("error", /* @__PURE__ */ new Error(`broker rejected answer ${id}`));
|
|
1566
|
+
}
|
|
1567
|
+
#markDisconnected() {
|
|
1568
|
+
if (!this.#connected) return;
|
|
1569
|
+
this.#connected = false;
|
|
1570
|
+
this.#emitter.emit("disconnect");
|
|
1571
|
+
}
|
|
1572
|
+
#headers(base) {
|
|
1573
|
+
if (this.#token !== void 0) base[HEADER_TOKEN] = this.#token;
|
|
1574
|
+
return base;
|
|
1575
|
+
}
|
|
1576
|
+
#wait(ms) {
|
|
1577
|
+
return new Promise((resolve) => {
|
|
1578
|
+
const settle = () => {
|
|
1579
|
+
this.#backoff = void 0;
|
|
1580
|
+
this.#wake = void 0;
|
|
1581
|
+
resolve();
|
|
1582
|
+
};
|
|
1583
|
+
this.#wake = settle;
|
|
1584
|
+
this.#backoff = this.#timer(() => {
|
|
1585
|
+
settle();
|
|
1586
|
+
}, ms);
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
//#endregion
|
|
1591
|
+
//#region src/core/factories.ts
|
|
1592
|
+
/**
|
|
1593
|
+
* Create the headless prompt {@link PromptInterface} BROKER — it parks each prompt call as a
|
|
1594
|
+
* pending prompt and resolves it when {@link PromptInterface.answer} arrives (or rejects on
|
|
1595
|
+
* timeout). The tri-surface's headless arm: subscribe `emitter.on('pending', …)` to forward each
|
|
1596
|
+
* prompt to whoever can answer (an SSE transport, a remote terminal), then route the answer back
|
|
1597
|
+
* through `answer(id, value)`.
|
|
1598
|
+
*
|
|
1599
|
+
* @param options - See {@link PromptOptions}
|
|
1600
|
+
* @returns A {@link PromptInterface}
|
|
1601
|
+
*
|
|
1602
|
+
* @remarks
|
|
1603
|
+
* - **Park-as-Promise.** `await prompt.input({ message })` blocks until `answer(id, value)` accepts
|
|
1604
|
+
* a matching value; the value is validated (text forms) and type-checked to the form first.
|
|
1605
|
+
* - **Timeout → expire → reject (deterministic).** An unanswered prompt expires after
|
|
1606
|
+
* `options.timeout` (default {@link import('./constants.js').DEFAULT_PROMPT_TIMEOUT_MS}) and its
|
|
1607
|
+
* Promise rejects with a {@link import('./errors.js').TerminalError}; inject `options.timer` to
|
|
1608
|
+
* drive expiry without real time.
|
|
1609
|
+
*
|
|
1610
|
+
* @example
|
|
1611
|
+
* ```ts
|
|
1612
|
+
* import { createPrompt } from '@src/core'
|
|
1613
|
+
*
|
|
1614
|
+
* const prompt = createPrompt()
|
|
1615
|
+
* prompt.emitter.on('pending', (pending) => send(pending)) // forward to a remote client
|
|
1616
|
+
* const name = await prompt.input({ message: 'Your name', validate: { required: true } })
|
|
1617
|
+
* ```
|
|
1618
|
+
*/
|
|
1619
|
+
function createPrompt(options) {
|
|
1620
|
+
return new Prompt(options);
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Create the SSE prompt {@link PromptClientInterface} BRIDGE — it connects to a remote broker's SSE
|
|
1624
|
+
* endpoint, dispatches each received prompt to a LOCAL {@link import('./types.js').PromptFormInterface}
|
|
1625
|
+
* terminal (so a human at this machine answers a prompt parked elsewhere), and POSTs the answer
|
|
1626
|
+
* back. Universal — `fetch` / SSE are web-standard.
|
|
1627
|
+
*
|
|
1628
|
+
* @param options - See {@link PromptClientOptions} (`url` + `terminal` required)
|
|
1629
|
+
* @returns A {@link PromptClientInterface}
|
|
1630
|
+
*
|
|
1631
|
+
* @remarks
|
|
1632
|
+
* - **Connect + reconnect.** `await client.connect()` streams remote prompts until the stream
|
|
1633
|
+
* ends; it reconnects with the `delay` backoff unless `reconnect` is `false` / the client was
|
|
1634
|
+
* `destroy`ed. Inject `options.fetch` (a scripted `fetch`) and `options.timer` to drive it
|
|
1635
|
+
* deterministically in tests — no real network.
|
|
1636
|
+
* - **§14 wire narrowing.** Every decoded prompt is guard-narrowed before dispatch (never an `as`).
|
|
1637
|
+
*
|
|
1638
|
+
* @example
|
|
1639
|
+
* ```ts
|
|
1640
|
+
* import { createPromptClient } from '@src/core'
|
|
1641
|
+
*
|
|
1642
|
+
* const client = createPromptClient({ url: 'http://host/prompts', terminal })
|
|
1643
|
+
* await client.connect()
|
|
1644
|
+
* ```
|
|
1645
|
+
*/
|
|
1646
|
+
function createPromptClient(options) {
|
|
1647
|
+
return new PromptClient(options);
|
|
1648
|
+
}
|
|
1649
|
+
//#endregion
|
|
1650
|
+
exports.ACCEPT_EVENT_STREAM = ACCEPT_EVENT_STREAM;
|
|
1651
|
+
exports.ALPHANUMERIC_PATTERN = ALPHANUMERIC_PATTERN;
|
|
1652
|
+
exports.BACKSPACE = BACKSPACE;
|
|
1653
|
+
exports.CONTROL_NAMES = CONTROL_NAMES;
|
|
1654
|
+
exports.CTRL_A = CTRL_A;
|
|
1655
|
+
exports.CTRL_C = CTRL_C;
|
|
1656
|
+
exports.CTRL_D = CTRL_D;
|
|
1657
|
+
exports.CTRL_E = CTRL_E;
|
|
1658
|
+
exports.CTRL_U = CTRL_U;
|
|
1659
|
+
exports.DEFAULT_MASK = DEFAULT_MASK;
|
|
1660
|
+
exports.DEFAULT_PROMPT_TIMEOUT_MS = DEFAULT_PROMPT_TIMEOUT_MS;
|
|
1661
|
+
exports.DEFAULT_RECONNECT_DELAY_MS = DEFAULT_RECONNECT_DELAY_MS;
|
|
1662
|
+
exports.DELETE = DELETE;
|
|
1663
|
+
exports.EMAIL_PATTERN = EMAIL_PATTERN;
|
|
1664
|
+
exports.ESCAPE = ESCAPE;
|
|
1665
|
+
exports.HEADER_TOKEN = HEADER_TOKEN;
|
|
1666
|
+
exports.INTEGER_PATTERN = INTEGER_PATTERN;
|
|
1667
|
+
exports.KEY_CSI = KEY_CSI;
|
|
1668
|
+
exports.KEY_SS3 = KEY_SS3;
|
|
1669
|
+
exports.NEWLINE = NEWLINE;
|
|
1670
|
+
exports.NUMERIC_PATTERN = NUMERIC_PATTERN;
|
|
1671
|
+
exports.PROMPT_ICONS = PROMPT_ICONS;
|
|
1672
|
+
exports.Prompt = Prompt;
|
|
1673
|
+
exports.PromptClient = PromptClient;
|
|
1674
|
+
exports.RETURN = RETURN;
|
|
1675
|
+
exports.RULE_MESSAGES = RULE_MESSAGES;
|
|
1676
|
+
exports.SEQUENCE_NAMES = SEQUENCE_NAMES;
|
|
1677
|
+
exports.SPACE = SPACE;
|
|
1678
|
+
exports.SSE_BUFFER_LIMIT = SSE_BUFFER_LIMIT;
|
|
1679
|
+
exports.SSE_EVENTS = SSE_EVENTS;
|
|
1680
|
+
exports.TAB = TAB;
|
|
1681
|
+
exports.TerminalError = TerminalError;
|
|
1682
|
+
exports.URL_PATTERN = URL_PATTERN;
|
|
1683
|
+
exports.appendRule = appendRule;
|
|
1684
|
+
exports.buildRuleValidator = buildRuleValidator;
|
|
1685
|
+
exports.checkboxReduce = checkboxReduce;
|
|
1686
|
+
exports.checkboxView = checkboxView;
|
|
1687
|
+
exports.composeValidators = composeValidators;
|
|
1688
|
+
exports.confirmReduce = confirmReduce;
|
|
1689
|
+
exports.confirmView = confirmView;
|
|
1690
|
+
exports.createCheckboxState = createCheckboxState;
|
|
1691
|
+
exports.createConfirmState = createConfirmState;
|
|
1692
|
+
exports.createEditorState = createEditorState;
|
|
1693
|
+
exports.createInputState = createInputState;
|
|
1694
|
+
exports.createPasswordState = createPasswordState;
|
|
1695
|
+
exports.createPrompt = createPrompt;
|
|
1696
|
+
exports.createPromptClient = createPromptClient;
|
|
1697
|
+
exports.createSelectState = createSelectState;
|
|
1698
|
+
exports.defaultTimer = defaultTimer;
|
|
1699
|
+
exports.dispatchPendingPrompt = dispatchPendingPrompt;
|
|
1700
|
+
exports.editLine = editLine;
|
|
1701
|
+
exports.editorReduce = editorReduce;
|
|
1702
|
+
exports.editorView = editorView;
|
|
1703
|
+
exports.errorLine = errorLine;
|
|
1704
|
+
exports.evaluateRule = evaluateRule;
|
|
1705
|
+
exports.gateSelection = gateSelection;
|
|
1706
|
+
exports.globalFetch = globalFetch;
|
|
1707
|
+
exports.inputReduce = inputReduce;
|
|
1708
|
+
exports.inputView = inputView;
|
|
1709
|
+
exports.isAbortError = isAbortError;
|
|
1710
|
+
exports.isCheckboxChoice = isCheckboxChoice;
|
|
1711
|
+
exports.isInsecureRemote = isInsecureRemote;
|
|
1712
|
+
exports.isPendingPrompt = isPendingPrompt;
|
|
1713
|
+
exports.isPendingPromptStatus = isPendingPromptStatus;
|
|
1714
|
+
exports.isPrintable = isPrintable;
|
|
1715
|
+
exports.isPromptChoice = isPromptChoice;
|
|
1716
|
+
exports.isPromptType = isPromptType;
|
|
1717
|
+
exports.isTerminalError = isTerminalError;
|
|
1718
|
+
exports.normalizeCheckboxChoice = normalizeCheckboxChoice;
|
|
1719
|
+
exports.normalizeChoice = normalizeChoice;
|
|
1720
|
+
exports.parseKey = parseKey;
|
|
1721
|
+
exports.parseWireJSON = parseWireJSON;
|
|
1722
|
+
exports.passing = passing;
|
|
1723
|
+
exports.passwordReduce = passwordReduce;
|
|
1724
|
+
exports.passwordView = passwordView;
|
|
1725
|
+
exports.promptHeader = promptHeader;
|
|
1726
|
+
exports.reconstructValidationRules = reconstructValidationRules;
|
|
1727
|
+
exports.resolveChoices = resolveChoices;
|
|
1728
|
+
exports.resolveOption = resolveOption;
|
|
1729
|
+
exports.resolveValidation = resolveValidation;
|
|
1730
|
+
exports.sanitizeChoiceLabels = sanitizeChoiceLabels;
|
|
1731
|
+
exports.selectReduce = selectReduce;
|
|
1732
|
+
exports.selectView = selectView;
|
|
1733
|
+
exports.serializeChoices = serializeChoices;
|
|
1734
|
+
exports.serializePromptOptions = serializePromptOptions;
|
|
1735
|
+
exports.serializeValidationRules = serializeValidationRules;
|
|
1736
|
+
exports.submitHeader = submitHeader;
|
|
1737
|
+
exports.toggleIndex = toggleIndex;
|
|
1738
|
+
|
|
1739
|
+
//# sourceMappingURL=index.cjs.map
|