@orkestrel/terminal 0.0.7 → 0.0.9
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/README.md +51 -33
- package/dist/src/core/index.cjs +785 -869
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +627 -862
- package/dist/src/core/index.d.ts +627 -862
- package/dist/src/core/index.js +777 -841
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +555 -205
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +232 -129
- package/dist/src/server/index.d.ts +232 -129
- package/dist/src/server/index.js +540 -206
- package/dist/src/server/index.js.map +1 -1
- package/package.json +8 -6
package/dist/src/core/index.cjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
let _orkestrel_contract = require("@orkestrel/contract");
|
|
3
2
|
let _orkestrel_console = require("@orkestrel/console");
|
|
3
|
+
let _orkestrel_contract = require("@orkestrel/contract");
|
|
4
4
|
let _orkestrel_emitter = require("@orkestrel/emitter");
|
|
5
|
+
let _orkestrel_form = require("@orkestrel/form");
|
|
5
6
|
let _orkestrel_sse = require("@orkestrel/sse");
|
|
6
7
|
let _orkestrel_database = require("@orkestrel/database");
|
|
7
8
|
//#region src/core/constants.ts
|
|
@@ -128,42 +129,11 @@ var CONTROL_NAMES = Object.freeze({
|
|
|
128
129
|
ctrl: true
|
|
129
130
|
})
|
|
130
131
|
});
|
|
131
|
-
/** The default mask glyph
|
|
132
|
+
/** The default mask glyph {@link import('./helpers.js').createPasswordState} uses — `*`. */
|
|
132
133
|
var DEFAULT_MASK = "*";
|
|
133
|
-
/** Matches an email address — a non-trivial `local@domain.tld` shape. The `email` rule tests against this. */
|
|
134
|
-
var EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
135
|
-
/** Matches an HTTP(S) URL. The `url` rule tests against this. */
|
|
136
|
-
var URL_PATTERN = /^https?:\/\/.+/;
|
|
137
|
-
/** Matches a numeric value (integer or decimal, optional sign). The `numeric` rule tests against this. */
|
|
138
|
-
var NUMERIC_PATTERN = /^-?\d+(\.\d+)?$/;
|
|
139
|
-
/** Matches an integer (optional sign). The `integer` rule tests against this. */
|
|
140
|
-
var INTEGER_PATTERN = /^-?\d+$/;
|
|
141
|
-
/** Matches an alphanumeric string (letters and digits only). The `alphanumeric` rule tests against this. */
|
|
142
|
-
var ALPHANUMERIC_PATTERN = /^[a-zA-Z0-9]+$/;
|
|
143
|
-
/**
|
|
144
|
-
* Each built-in validation rule's default error message — what the composed {@link
|
|
145
|
-
* import('./types.js').Validator} returns when that rule fails (the `minimum` / `maximum`
|
|
146
|
-
* messages are interpolated with the configured length at build time). Frozen; the source of
|
|
147
|
-
* truth for the rule-failure copy.
|
|
148
|
-
*/
|
|
149
|
-
var RULE_MESSAGES = Object.freeze({
|
|
150
|
-
required: "This field is required",
|
|
151
|
-
minimum: "Must be at least {count} characters",
|
|
152
|
-
maximum: "Must be at most {count} characters",
|
|
153
|
-
pattern: "Must match pattern: {pattern}",
|
|
154
|
-
email: "Must be a valid email address",
|
|
155
|
-
url: "Must be a valid URL",
|
|
156
|
-
numeric: "Must be a numeric value",
|
|
157
|
-
integer: "Must be an integer",
|
|
158
|
-
alphanumeric: "Must contain only letters and digits",
|
|
159
|
-
invalid: "Invalid input"
|
|
160
|
-
});
|
|
161
134
|
/**
|
|
162
|
-
* The
|
|
163
|
-
*
|
|
164
|
-
* {@link import('@orkestrel/console').StylerInterface} at render time, never baked into the
|
|
165
|
-
* constant (AGENTS — styling orthogonal to data; the rework fixes the terminal's ANSI-in-the-icon).
|
|
166
|
-
* Frozen.
|
|
135
|
+
* The six default glyphs {@link DEFAULT_PROMPT_THEME} assembles its `icons` from. Read only when
|
|
136
|
+
* the default theme is assembled; a view reads its resolved theme and never this constant. Frozen.
|
|
167
137
|
*
|
|
168
138
|
* @remarks
|
|
169
139
|
* - `question` — the leading mark on a prompt's message line.
|
|
@@ -179,16 +149,89 @@ var PROMPT_ICONS = Object.freeze({
|
|
|
179
149
|
checked: "☑",
|
|
180
150
|
unchecked: "☐"
|
|
181
151
|
});
|
|
152
|
+
/**
|
|
153
|
+
* Every {@link import('./types.js').PromptRole}, in one frozen list — the role axis's source of
|
|
154
|
+
* truth. {@link import('./helpers.js').createPromptTheme} walks it to merge a partial theme, and
|
|
155
|
+
* a consumer building a complete role map reads it rather than retyping every name.
|
|
156
|
+
*/
|
|
157
|
+
var PROMPT_ROLES = Object.freeze([
|
|
158
|
+
"question",
|
|
159
|
+
"pointer",
|
|
160
|
+
"message",
|
|
161
|
+
"content",
|
|
162
|
+
"success",
|
|
163
|
+
"error",
|
|
164
|
+
"selected",
|
|
165
|
+
"focus",
|
|
166
|
+
"hint",
|
|
167
|
+
"muted",
|
|
168
|
+
"description"
|
|
169
|
+
]);
|
|
170
|
+
/**
|
|
171
|
+
* The {@link import('./types.js').PromptTheme} every prompt renders with unless its options supply
|
|
172
|
+
* another — the glyph set assembled from {@link PROMPT_ICONS} plus the console
|
|
173
|
+
* {@link import('@orkestrel/console').STATUS_ICONS} `success` / `error` marks, and the console
|
|
174
|
+
* {@link import('@orkestrel/console').Style} each role is painted with. Deeply frozen through the
|
|
175
|
+
* console module's own {@link import('@orkestrel/console').freezeStyle}; the baseline
|
|
176
|
+
* {@link import('./helpers.js').createPromptTheme} merges a partial theme over.
|
|
177
|
+
*
|
|
178
|
+
* @remarks
|
|
179
|
+
* The default roles reproduce the views' historical coloring exactly: `question` / `pointer` cyan,
|
|
180
|
+
* `message` / `focus` bold, `success` / `selected` green, `error` red, `hint` / `muted` /
|
|
181
|
+
* `description` dim, and `content` the EMPTY style — an empty style renders bare text, so primary
|
|
182
|
+
* content keeps the bytes it had before it became themeable.
|
|
183
|
+
* Two roles sharing a style today are still two roles — re-mapping one leaves the other alone.
|
|
184
|
+
*/
|
|
185
|
+
var DEFAULT_PROMPT_THEME = Object.freeze({
|
|
186
|
+
icons: Object.freeze({
|
|
187
|
+
question: PROMPT_ICONS.question,
|
|
188
|
+
pointer: PROMPT_ICONS.pointer,
|
|
189
|
+
dot: PROMPT_ICONS.dot,
|
|
190
|
+
selected: PROMPT_ICONS.selected,
|
|
191
|
+
checked: PROMPT_ICONS.checked,
|
|
192
|
+
unchecked: PROMPT_ICONS.unchecked,
|
|
193
|
+
success: _orkestrel_console.STATUS_ICONS.success,
|
|
194
|
+
error: _orkestrel_console.STATUS_ICONS.error
|
|
195
|
+
}),
|
|
196
|
+
roles: Object.freeze({
|
|
197
|
+
question: (0, _orkestrel_console.freezeStyle)({
|
|
198
|
+
foreground: "cyan",
|
|
199
|
+
attributes: []
|
|
200
|
+
}),
|
|
201
|
+
pointer: (0, _orkestrel_console.freezeStyle)({
|
|
202
|
+
foreground: "cyan",
|
|
203
|
+
attributes: []
|
|
204
|
+
}),
|
|
205
|
+
message: (0, _orkestrel_console.freezeStyle)({ attributes: ["bold"] }),
|
|
206
|
+
content: (0, _orkestrel_console.freezeStyle)({ attributes: [] }),
|
|
207
|
+
success: (0, _orkestrel_console.freezeStyle)({
|
|
208
|
+
foreground: "green",
|
|
209
|
+
attributes: []
|
|
210
|
+
}),
|
|
211
|
+
error: (0, _orkestrel_console.freezeStyle)({
|
|
212
|
+
foreground: "red",
|
|
213
|
+
attributes: []
|
|
214
|
+
}),
|
|
215
|
+
selected: (0, _orkestrel_console.freezeStyle)({
|
|
216
|
+
foreground: "green",
|
|
217
|
+
attributes: []
|
|
218
|
+
}),
|
|
219
|
+
focus: (0, _orkestrel_console.freezeStyle)({ attributes: ["bold"] }),
|
|
220
|
+
hint: (0, _orkestrel_console.freezeStyle)({ attributes: ["dim"] }),
|
|
221
|
+
muted: (0, _orkestrel_console.freezeStyle)({ attributes: ["dim"] }),
|
|
222
|
+
description: (0, _orkestrel_console.freezeStyle)({ attributes: ["dim"] })
|
|
223
|
+
})
|
|
224
|
+
});
|
|
182
225
|
/** How long (ms) the {@link import('./types.js').PromptInterface} broker parks an unanswered prompt before it expires — 5 minutes. */
|
|
183
226
|
var DEFAULT_PROMPT_TIMEOUT_MS = 3e5;
|
|
184
227
|
/** How long (ms) the {@link import('./types.js').PromptClientInterface} waits before each reconnect attempt — 2 seconds. */
|
|
185
228
|
var DEFAULT_RECONNECT_DELAY_MS = 2e3;
|
|
186
229
|
/**
|
|
187
|
-
* The SSE `event:` names the broker emits and the {@link import('./types.js').
|
|
230
|
+
* The SSE `event:` names the broker emits and the {@link import('./types.js').PromptClientInterface}
|
|
188
231
|
* dispatches on. Frozen; the source of truth for the wire event vocabulary.
|
|
189
232
|
*
|
|
190
233
|
* @remarks
|
|
191
|
-
* - `pending` — a serialized {@link import('./types.js').
|
|
234
|
+
* - `pending` — a serialized {@link import('./types.js').PendingForm} to dispatch and answer.
|
|
192
235
|
* - `expire` — an `{ id }` payload: the broker expired a parked prompt (the client drops it).
|
|
193
236
|
* - `shutdown` — the broker is going away; the client disconnects (no auto-reconnect) but stays reusable.
|
|
194
237
|
*/
|
|
@@ -197,12 +240,12 @@ var SSE_EVENTS = Object.freeze({
|
|
|
197
240
|
expire: "expire",
|
|
198
241
|
shutdown: "shutdown"
|
|
199
242
|
});
|
|
200
|
-
/** The auth-token request header the {@link import('./types.js').
|
|
243
|
+
/** The auth-token request header the {@link import('./types.js').PromptClientInterface} sends. */
|
|
201
244
|
var HEADER_TOKEN = "x-orkestrel-token";
|
|
202
245
|
/** The `Accept` header value that opens the broker's SSE stream. */
|
|
203
246
|
var ACCEPT_EVENT_STREAM = "text/event-stream";
|
|
204
247
|
/**
|
|
205
|
-
* The maximum number of characters the {@link import('./types.js').
|
|
248
|
+
* The maximum number of characters the {@link import('./types.js').PromptClientInterface} lets its
|
|
206
249
|
* SSE parser buffer before treating the stream as hostile — 1 MiB, comfortably above any
|
|
207
250
|
* legitimate prompt payload. Passed as the `limit` to `createSSEParser` so an unterminated
|
|
208
251
|
* or oversized `data:` field cannot grow the buffer without bound (a memory-exhaustion guard).
|
|
@@ -211,19 +254,20 @@ var SSE_BUFFER_LIMIT = 1048576;
|
|
|
211
254
|
//#endregion
|
|
212
255
|
//#region src/core/errors.ts
|
|
213
256
|
/**
|
|
214
|
-
*
|
|
257
|
+
* The error the terminal surfaces for its own refusals: parking on a destroyed or full broker, an
|
|
258
|
+
* unusable driver stream, a manager routing fault, or a ctrl-c cancellation. A parked form's own
|
|
259
|
+
* lifecycle failures reject through the form's `answer` with the form package's error, never with
|
|
260
|
+
* this one.
|
|
215
261
|
*
|
|
216
262
|
* @remarks
|
|
217
|
-
* Carries a {@link TerminalErrorCode} and an optional `context` bag
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* `EXPIRE`); or when the user aborts an interactive server-`Terminal` prompt with ctrl-c
|
|
221
|
-
* (`CANCEL`). Narrow a caught value with {@link isTerminalError} and branch on `error.code`.
|
|
263
|
+
* Carries a {@link TerminalErrorCode} and an optional `context` bag naming the offending values:
|
|
264
|
+
* `{ cap }` on `LIMIT`, `{ to, known }` on `TARGET`, and `{ from, to, path }` on `DEADLOCK`. Narrow
|
|
265
|
+
* a caught value with {@link isTerminalError} and branch on `error.code`.
|
|
222
266
|
*/
|
|
223
267
|
var TerminalError = class extends Error {
|
|
224
268
|
/** The machine-readable condition — see {@link TerminalErrorCode}. */
|
|
225
269
|
code;
|
|
226
|
-
/** An optional context bag naming the offending
|
|
270
|
+
/** An optional context bag naming the offending values — see the class {@link TerminalError remarks}. */
|
|
227
271
|
context;
|
|
228
272
|
constructor(code, message, context) {
|
|
229
273
|
super(message);
|
|
@@ -241,9 +285,11 @@ var TerminalError = class extends Error {
|
|
|
241
285
|
* @example
|
|
242
286
|
* ```ts
|
|
243
287
|
* try {
|
|
244
|
-
*
|
|
288
|
+
* await terminal.ask(form)
|
|
245
289
|
* } catch (error) {
|
|
246
|
-
* if (isTerminalError(error) && error.code === '
|
|
290
|
+
* if (isTerminalError(error) && error.code === 'CANCEL') {
|
|
291
|
+
* // the person aborted
|
|
292
|
+
* }
|
|
247
293
|
* }
|
|
248
294
|
* ```
|
|
249
295
|
*/
|
|
@@ -252,26 +298,39 @@ function isTerminalError(value) {
|
|
|
252
298
|
}
|
|
253
299
|
//#endregion
|
|
254
300
|
//#region src/core/validators.ts
|
|
255
|
-
/** Narrow an unknown value to a {@link
|
|
256
|
-
var
|
|
257
|
-
/** Narrow an unknown value to a {@link PendingPromptStatus}. */
|
|
258
|
-
var isPendingPromptStatus = (0, _orkestrel_contract.literalOf)("pending", "answered", "expired");
|
|
301
|
+
/** Narrow an unknown value to a {@link PendingFormStatus}. */
|
|
302
|
+
var isPendingFormStatus = (0, _orkestrel_contract.literalOf)("pending", "answered", "expired");
|
|
259
303
|
/**
|
|
260
|
-
* Narrow an unknown wire value to a {@link
|
|
304
|
+
* Narrow an unknown wire value to a {@link PendingForm} envelope.
|
|
305
|
+
*
|
|
306
|
+
* @remarks
|
|
307
|
+
* This guard checks the transport record and proves only that `schema` is a record. The Form
|
|
308
|
+
* package's `parseForm` owns the schema payload and its semantic audit.
|
|
261
309
|
*
|
|
262
310
|
* @param value - The decoded wire value to inspect
|
|
263
|
-
* @returns Whether
|
|
311
|
+
* @returns Whether the value is a complete pending-form envelope
|
|
264
312
|
*/
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
to
|
|
274
|
-
}
|
|
313
|
+
function isPendingForm(value) {
|
|
314
|
+
return (0, _orkestrel_contract.recordOf)({
|
|
315
|
+
id: _orkestrel_contract.isNonEmptyString,
|
|
316
|
+
schema: _orkestrel_contract.isRecord,
|
|
317
|
+
status: isPendingFormStatus,
|
|
318
|
+
time: _orkestrel_contract.isNumber,
|
|
319
|
+
from: _orkestrel_contract.isString,
|
|
320
|
+
to: _orkestrel_contract.isString
|
|
321
|
+
}, ["from", "to"])(value);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Narrow an unknown value to a transport-neutral {@link WireEvent}.
|
|
325
|
+
*
|
|
326
|
+
* @param value - The candidate wire event
|
|
327
|
+
* @returns Whether the value carries an event name, serialized data, and an optional id
|
|
328
|
+
*/
|
|
329
|
+
var isWireEvent = (0, _orkestrel_contract.recordOf)({
|
|
330
|
+
event: _orkestrel_contract.isString,
|
|
331
|
+
data: _orkestrel_contract.isString,
|
|
332
|
+
id: _orkestrel_contract.isString
|
|
333
|
+
}, ["id"]);
|
|
275
334
|
/** Narrow an unknown value to a {@link TerminalSnapshot}. */
|
|
276
335
|
var isTerminalSnapshot = (0, _orkestrel_contract.recordOf)({
|
|
277
336
|
id: _orkestrel_contract.isNonEmptyString,
|
|
@@ -352,188 +411,246 @@ function isPrintable(character) {
|
|
|
352
411
|
return code >= 32 && code !== 127;
|
|
353
412
|
}
|
|
354
413
|
/**
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
414
|
+
* Build a complete {@link PromptTheme} by merging a partial one over
|
|
415
|
+
* {@link DEFAULT_PROMPT_THEME}, leaf by leaf — each supplied icon replaces that glyph, each
|
|
416
|
+
* supplied role replaces that {@link Style}, and everything else keeps its default. Each supplied
|
|
417
|
+
* style is snapshotted through the console module's own
|
|
418
|
+
* {@link import('@orkestrel/console').freezeStyle}, so the result is deeply frozen and a caller
|
|
419
|
+
* mutating its own attribute list afterwards cannot reach into a built theme.
|
|
358
420
|
*
|
|
359
|
-
* @
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*/
|
|
371
|
-
function evaluateRule(rule, check, input) {
|
|
372
|
-
if (typeof check === "function") {
|
|
373
|
-
const result = check(input);
|
|
374
|
-
if (result === true) return void 0;
|
|
375
|
-
return (0, _orkestrel_contract.isString)(result) ? result : RULE_MESSAGES.invalid;
|
|
376
|
-
}
|
|
377
|
-
switch (rule) {
|
|
378
|
-
case "required":
|
|
379
|
-
if (check === true && input.trim().length === 0) return RULE_MESSAGES.required;
|
|
380
|
-
break;
|
|
381
|
-
case "minimum":
|
|
382
|
-
if ((0, _orkestrel_contract.isNumber)(check) && [...input].length < check) return RULE_MESSAGES.minimum.replace("{count}", String(check));
|
|
383
|
-
break;
|
|
384
|
-
case "maximum":
|
|
385
|
-
if ((0, _orkestrel_contract.isNumber)(check) && [...input].length > check) return RULE_MESSAGES.maximum.replace("{count}", String(check));
|
|
386
|
-
break;
|
|
387
|
-
case "pattern":
|
|
388
|
-
if ((0, _orkestrel_contract.isString)(check)) {
|
|
389
|
-
let compiled;
|
|
390
|
-
try {
|
|
391
|
-
compiled = new RegExp(check);
|
|
392
|
-
} catch {
|
|
393
|
-
return RULE_MESSAGES.pattern.replace("{pattern}", check);
|
|
394
|
-
}
|
|
395
|
-
if (!compiled.test(input)) return RULE_MESSAGES.pattern.replace("{pattern}", check);
|
|
396
|
-
}
|
|
397
|
-
break;
|
|
398
|
-
case "email":
|
|
399
|
-
if (check === true && !EMAIL_PATTERN.test(input)) return RULE_MESSAGES.email;
|
|
400
|
-
break;
|
|
401
|
-
case "url":
|
|
402
|
-
if (check === true && !URL_PATTERN.test(input)) return RULE_MESSAGES.url;
|
|
403
|
-
break;
|
|
404
|
-
case "numeric":
|
|
405
|
-
if (check === true && !NUMERIC_PATTERN.test(input)) return RULE_MESSAGES.numeric;
|
|
406
|
-
break;
|
|
407
|
-
case "integer":
|
|
408
|
-
if (check === true && !INTEGER_PATTERN.test(input)) return RULE_MESSAGES.integer;
|
|
409
|
-
break;
|
|
410
|
-
case "alphanumeric":
|
|
411
|
-
if (check === true && !ALPHANUMERIC_PATTERN.test(input)) return RULE_MESSAGES.alphanumeric;
|
|
412
|
-
break;
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
/** Wrap a named rule + its primitive check into a {@link Validator} (returns `true` or the rule's message). */
|
|
416
|
-
function buildRuleValidator(rule, check) {
|
|
417
|
-
return (input) => evaluateRule(rule, check, input) ?? true;
|
|
418
|
-
}
|
|
419
|
-
/**
|
|
420
|
-
* Append a rule-backed {@link Validator} to `validators` when the rule is enabled — a `false` /
|
|
421
|
-
* `undefined` `check` is skipped, a function `check` is added verbatim (the custom override), and
|
|
422
|
-
* a primitive `check` is wrapped via {@link buildRuleValidator}. Mutates `validators` in place.
|
|
421
|
+
* @param options - The partial theme to merge, or `undefined` for the defaults
|
|
422
|
+
* @returns The resolved, deeply frozen theme every prompt state carries
|
|
423
|
+
*
|
|
424
|
+
* @example
|
|
425
|
+
* ```ts
|
|
426
|
+
* createPromptTheme() // the defaults
|
|
427
|
+
* createPromptTheme({
|
|
428
|
+
* icons: { pointer: '=>' },
|
|
429
|
+
* roles: { message: { foreground: 'magenta', attributes: ['bold'] } },
|
|
430
|
+
* })
|
|
431
|
+
* ```
|
|
423
432
|
*/
|
|
424
|
-
function
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
|
|
433
|
+
function createPromptTheme(options) {
|
|
434
|
+
const icons = {
|
|
435
|
+
...DEFAULT_PROMPT_THEME.icons,
|
|
436
|
+
...options?.icons
|
|
437
|
+
};
|
|
438
|
+
const roles = { ...DEFAULT_PROMPT_THEME.roles };
|
|
439
|
+
for (const role of PROMPT_ROLES) {
|
|
440
|
+
const style = options?.roles?.[role];
|
|
441
|
+
if (style !== void 0) roles[role] = (0, _orkestrel_console.freezeStyle)(style);
|
|
442
|
+
}
|
|
443
|
+
return Object.freeze({
|
|
444
|
+
icons: Object.freeze(icons),
|
|
445
|
+
roles: Object.freeze(roles)
|
|
446
|
+
});
|
|
431
447
|
}
|
|
432
448
|
/**
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
449
|
+
* Sanitize text for one single-line display slot. Composes console's ANSI {@link strip} and C0
|
|
450
|
+
* {@link stripControls} passes with removal of tab, line feed, and carriage return.
|
|
451
|
+
*
|
|
452
|
+
* @param text - The text to sanitize for a glyph or hint slot
|
|
453
|
+
* @returns The text with ANSI sequences, every C0 control character, and DEL removed
|
|
454
|
+
*
|
|
455
|
+
* @example
|
|
456
|
+
* ```ts
|
|
457
|
+
* sanitizeDisplayText('Q\rOVERWRITE\nNEXT\tX') // 'QOVERWRITENEXTX'
|
|
458
|
+
* ```
|
|
436
459
|
*/
|
|
437
|
-
function
|
|
438
|
-
return (
|
|
439
|
-
for (const validator of validators) {
|
|
440
|
-
const result = validator(input);
|
|
441
|
-
if (result !== true) return result;
|
|
442
|
-
}
|
|
443
|
-
return true;
|
|
444
|
-
};
|
|
460
|
+
function sanitizeDisplayText(text) {
|
|
461
|
+
return (0, _orkestrel_console.stripControls)((0, _orkestrel_console.strip)(text)).replaceAll(" ", "").replaceAll("\n", "").replaceAll("\r", "");
|
|
445
462
|
}
|
|
446
463
|
/**
|
|
447
|
-
*
|
|
448
|
-
* composed {@link Validator}. A bare validator passes through; rules are appended in the fixed
|
|
449
|
-
* order (required → minimum → maximum → pattern → email → url → numeric → integer → alphanumeric
|
|
450
|
-
* → custom) and composed; absent / empty input yields an always-passing validator. Pure.
|
|
464
|
+
* Sanitize every terminal-readable string in a parsed form schema.
|
|
451
465
|
*
|
|
452
466
|
* @remarks
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
467
|
+
* Display strings pass through {@link sanitizeDisplayText}: labels, help, placeholders, masks,
|
|
468
|
+
* choice labels and help, file accept entries, and pattern sources. Identity and answer strings
|
|
469
|
+
* stay verbatim: schema, group, and field names, group references, choice values, and defaults.
|
|
470
|
+
* Rewriting those would sever the rendering copy from the authoritative form. Field metadata is
|
|
471
|
+
* removed because terminal neither renders nor interprets it. Pattern sources are sanitized as
|
|
472
|
+
* text only and are never compiled or executed here.
|
|
457
473
|
*
|
|
458
|
-
* @param
|
|
459
|
-
* @returns
|
|
474
|
+
* @param schema - A schema already accepted by the Form package's `parseForm`
|
|
475
|
+
* @returns A new schema with terminal-readable strings sanitized and field metadata omitted
|
|
460
476
|
*
|
|
461
477
|
* @example
|
|
462
478
|
* ```ts
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
* v('ab') // 'Must be at least 3 characters'
|
|
466
|
-
* v('abc') // true
|
|
479
|
+
* sanitizeSchema({ fields: [{ control: 'text', name: 'na\u001bme', label: 'N\u0000ame' }] })
|
|
480
|
+
* // { fields: [{ control: 'text', name: 'na\u001bme', label: 'Name' }] }
|
|
467
481
|
* ```
|
|
468
482
|
*/
|
|
469
|
-
function
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
483
|
+
function sanitizeSchema(schema) {
|
|
484
|
+
const groups = schema.groups?.map((group) => ({
|
|
485
|
+
name: group.name,
|
|
486
|
+
label: sanitizeDisplayText(group.label),
|
|
487
|
+
...group.help !== void 0 ? { help: sanitizeDisplayText(group.help) } : {}
|
|
488
|
+
}));
|
|
489
|
+
const fields = [];
|
|
490
|
+
for (const source of schema.fields) {
|
|
491
|
+
const { meta: _meta, ...field } = source;
|
|
492
|
+
const rule = field.rule === void 0 ? void 0 : {
|
|
493
|
+
...field.rule,
|
|
494
|
+
...field.rule.pattern !== void 0 ? { pattern: sanitizeDisplayText(field.rule.pattern) } : {}
|
|
495
|
+
};
|
|
496
|
+
const shared = {
|
|
497
|
+
name: field.name,
|
|
498
|
+
...field.label !== void 0 ? { label: sanitizeDisplayText(field.label) } : {},
|
|
499
|
+
...field.help !== void 0 ? { help: sanitizeDisplayText(field.help) } : {},
|
|
500
|
+
...field.group !== void 0 ? { group: field.group } : {},
|
|
501
|
+
...rule !== void 0 ? { rule } : {}
|
|
502
|
+
};
|
|
503
|
+
switch (field.control) {
|
|
504
|
+
case "text":
|
|
505
|
+
case "editor":
|
|
506
|
+
fields.push({
|
|
507
|
+
...field,
|
|
508
|
+
...shared,
|
|
509
|
+
...field.placeholder !== void 0 ? { placeholder: sanitizeDisplayText(field.placeholder) } : {}
|
|
510
|
+
});
|
|
511
|
+
break;
|
|
512
|
+
case "password":
|
|
513
|
+
fields.push({
|
|
514
|
+
...field,
|
|
515
|
+
...shared,
|
|
516
|
+
...field.mask !== void 0 ? { mask: sanitizeDisplayText(field.mask) } : {}
|
|
517
|
+
});
|
|
518
|
+
break;
|
|
519
|
+
case "number":
|
|
520
|
+
fields.push({
|
|
521
|
+
...field,
|
|
522
|
+
...shared,
|
|
523
|
+
...field.placeholder !== void 0 ? { placeholder: sanitizeDisplayText(field.placeholder) } : {}
|
|
524
|
+
});
|
|
525
|
+
break;
|
|
526
|
+
case "date":
|
|
527
|
+
case "time":
|
|
528
|
+
case "datetime":
|
|
529
|
+
case "color":
|
|
530
|
+
fields.push({
|
|
531
|
+
...field,
|
|
532
|
+
...shared
|
|
533
|
+
});
|
|
534
|
+
break;
|
|
535
|
+
case "confirm":
|
|
536
|
+
fields.push({
|
|
537
|
+
...field,
|
|
538
|
+
...shared
|
|
539
|
+
});
|
|
540
|
+
break;
|
|
541
|
+
case "select":
|
|
542
|
+
fields.push({
|
|
543
|
+
...field,
|
|
544
|
+
...shared,
|
|
545
|
+
choices: field.choices.map((choice) => ({
|
|
546
|
+
...choice,
|
|
547
|
+
value: choice.value,
|
|
548
|
+
label: sanitizeDisplayText(choice.label),
|
|
549
|
+
...choice.help !== void 0 ? { help: sanitizeDisplayText(choice.help) } : {}
|
|
550
|
+
}))
|
|
551
|
+
});
|
|
552
|
+
break;
|
|
553
|
+
case "checkbox":
|
|
554
|
+
fields.push({
|
|
555
|
+
...field,
|
|
556
|
+
...shared,
|
|
557
|
+
choices: field.choices.map((choice) => ({
|
|
558
|
+
...choice,
|
|
559
|
+
value: choice.value,
|
|
560
|
+
label: sanitizeDisplayText(choice.label),
|
|
561
|
+
...choice.help !== void 0 ? { help: sanitizeDisplayText(choice.help) } : {}
|
|
562
|
+
}))
|
|
563
|
+
});
|
|
564
|
+
break;
|
|
565
|
+
case "file":
|
|
566
|
+
fields.push({
|
|
567
|
+
...field,
|
|
568
|
+
...shared,
|
|
569
|
+
...field.accept !== void 0 ? { accept: field.accept.map(sanitizeDisplayText) } : {}
|
|
570
|
+
});
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return {
|
|
575
|
+
...schema.name !== void 0 ? { name: schema.name } : {},
|
|
576
|
+
...schema.label !== void 0 ? { label: sanitizeDisplayText(schema.label) } : {},
|
|
577
|
+
...schema.help !== void 0 ? { help: sanitizeDisplayText(schema.help) } : {},
|
|
578
|
+
...groups !== void 0 ? { groups } : {},
|
|
579
|
+
fields
|
|
580
|
+
};
|
|
489
581
|
}
|
|
490
|
-
/**
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
582
|
+
/**
|
|
583
|
+
* Sanitize every glyph a wire-supplied {@link PromptThemeOptions} carries for a single-line display
|
|
584
|
+
* slot. Only the icons need it: a role is guard-narrowed to a console {@link Style}, whose colors
|
|
585
|
+
* and attributes are fixed name sets, so no role can carry a byte a terminal would act on.
|
|
586
|
+
*
|
|
587
|
+
* @param theme - The narrowed theme options a remote prompt supplied
|
|
588
|
+
* @returns The same theme with every supplied glyph sanitized for a single-line display slot
|
|
589
|
+
*/
|
|
590
|
+
function sanitizeThemeIcons(theme) {
|
|
591
|
+
const icons = theme.icons;
|
|
592
|
+
if (icons === void 0) return theme;
|
|
593
|
+
const sanitized = {};
|
|
594
|
+
for (const [icon, glyph] of Object.entries(icons)) sanitized[icon] = sanitizeDisplayText(glyph);
|
|
595
|
+
return {
|
|
596
|
+
...theme,
|
|
597
|
+
icons: sanitized
|
|
598
|
+
};
|
|
496
599
|
}
|
|
497
|
-
/**
|
|
498
|
-
function
|
|
499
|
-
return (
|
|
500
|
-
name: choice,
|
|
501
|
-
value: choice
|
|
502
|
-
} : choice;
|
|
600
|
+
/** The styled prompt-message header (`? message`) — the leading line every active prompt view shares, themed by the `question` + `message` roles. */
|
|
601
|
+
function promptHeader(styler, theme, message) {
|
|
602
|
+
return `${styler.render(theme.roles.question, theme.icons.question)} ${styler.render(theme.roles.message, message)}`;
|
|
503
603
|
}
|
|
504
|
-
/**
|
|
505
|
-
|
|
506
|
-
|
|
604
|
+
/**
|
|
605
|
+
* Render a prompt header followed by a key hint painted with the `hint` role, or the header alone
|
|
606
|
+
* when no hint is supplied.
|
|
607
|
+
*
|
|
608
|
+
* @param styler - The console styler that renders each role
|
|
609
|
+
* @param theme - The resolved prompt theme
|
|
610
|
+
* @param message - The prompt's question text
|
|
611
|
+
* @param hint - The optional key hint to append
|
|
612
|
+
* @returns The rendered header with the optional hint
|
|
613
|
+
*/
|
|
614
|
+
function hintedHeader(styler, theme, message, hint) {
|
|
615
|
+
const head = promptHeader(styler, theme, message);
|
|
616
|
+
return hint === void 0 ? head : `${head} ${styler.render(theme.roles.hint, hint)}`;
|
|
507
617
|
}
|
|
508
|
-
/** The styled submit line (`✔ message`) — the committed header an interactive prompt shows once resolved. */
|
|
509
|
-
function submitHeader(styler, message) {
|
|
510
|
-
return `${styler.
|
|
618
|
+
/** The styled submit line (`✔ message`) — the committed header an interactive prompt shows once resolved, themed by the `success` + `message` roles. */
|
|
619
|
+
function submitHeader(styler, theme, message) {
|
|
620
|
+
return `${styler.render(theme.roles.success, theme.icons.success)} ${styler.render(theme.roles.message, message)}`;
|
|
511
621
|
}
|
|
512
|
-
/** The styled error line (`✖ message`)
|
|
513
|
-
function errorLine(styler, message) {
|
|
514
|
-
return `${styler.
|
|
622
|
+
/** The styled error line (`✖ message`) a form driver appends for a field failure. */
|
|
623
|
+
function errorLine(styler, theme, message) {
|
|
624
|
+
return `${styler.render(theme.roles.error, theme.icons.error)} ${styler.render(theme.roles.error, message)}`;
|
|
515
625
|
}
|
|
516
|
-
/**
|
|
517
|
-
|
|
626
|
+
/**
|
|
627
|
+
* Build the initial text-field key state.
|
|
628
|
+
*
|
|
629
|
+
* @param field - The text field to render
|
|
630
|
+
* @param styler - The styler used to render the view
|
|
631
|
+
* @param theme - The optional terminal theme
|
|
632
|
+
* @returns The initial immutable key state
|
|
633
|
+
*/
|
|
634
|
+
function createInputState(field, styler = (0, _orkestrel_console.createStyler)(), theme) {
|
|
518
635
|
return {
|
|
519
|
-
message:
|
|
520
|
-
default:
|
|
521
|
-
|
|
522
|
-
|
|
636
|
+
message: sanitizeDisplayText(field.label ?? field.name),
|
|
637
|
+
default: field.default ?? "",
|
|
638
|
+
styler,
|
|
639
|
+
theme: createPromptTheme(theme),
|
|
523
640
|
value: ""
|
|
524
641
|
};
|
|
525
642
|
}
|
|
526
|
-
/** Render
|
|
643
|
+
/** Render a text-field key state as a styled view. */
|
|
527
644
|
function inputView(state) {
|
|
528
|
-
const
|
|
529
|
-
const
|
|
530
|
-
|
|
645
|
+
const content = state.value.length > 0 ? state.value : state.default;
|
|
646
|
+
const role = state.value.length > 0 ? state.theme.roles.content : state.theme.roles.hint;
|
|
647
|
+
const shown = state.styler.render(role, sanitizeDisplayText(content));
|
|
648
|
+
return `${promptHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.pointer, state.theme.icons.pointer)} ${shown}`;
|
|
531
649
|
}
|
|
532
650
|
/**
|
|
533
651
|
* Advance an input prompt by one {@link KeyEvent} — the pure `(state, key) → PromptStep<string>`
|
|
534
652
|
* reducer. Printable characters extend the value; backspace shrinks it; ctrl-u clears it; ctrl-c
|
|
535
|
-
* cancels; return
|
|
536
|
-
* invalid submit stays active with the error in the view.
|
|
653
|
+
* cancels; return produces the candidate value, with an empty line falling back to the default.
|
|
537
654
|
*/
|
|
538
655
|
function inputReduce(state, key) {
|
|
539
656
|
if (key.ctrl && key.name === "c") return {
|
|
@@ -541,27 +658,14 @@ function inputReduce(state, key) {
|
|
|
541
658
|
view: inputView(state),
|
|
542
659
|
status: "cancel"
|
|
543
660
|
};
|
|
544
|
-
const { error: _error, ...clear } = state;
|
|
545
661
|
if (key.name === "return") {
|
|
546
662
|
const answer = state.value.length > 0 ? state.value : state.default;
|
|
547
|
-
const result = state.validator(answer);
|
|
548
|
-
if (result !== true) {
|
|
549
|
-
const next = {
|
|
550
|
-
...state,
|
|
551
|
-
error: result
|
|
552
|
-
};
|
|
553
|
-
return {
|
|
554
|
-
state: next,
|
|
555
|
-
view: inputView(next),
|
|
556
|
-
status: "active"
|
|
557
|
-
};
|
|
558
|
-
}
|
|
559
663
|
return {
|
|
560
664
|
state: {
|
|
561
|
-
...
|
|
665
|
+
...state,
|
|
562
666
|
value: answer
|
|
563
667
|
},
|
|
564
|
-
view: `${submitHeader(state.styler, state.message)} ${state.styler.
|
|
668
|
+
view: `${submitHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.hint, sanitizeDisplayText(answer))}`,
|
|
565
669
|
status: "submit",
|
|
566
670
|
value: answer
|
|
567
671
|
};
|
|
@@ -573,7 +677,7 @@ function inputReduce(state, key) {
|
|
|
573
677
|
status: "active"
|
|
574
678
|
};
|
|
575
679
|
const next = {
|
|
576
|
-
...
|
|
680
|
+
...state,
|
|
577
681
|
value
|
|
578
682
|
};
|
|
579
683
|
return {
|
|
@@ -582,27 +686,32 @@ function inputReduce(state, key) {
|
|
|
582
686
|
status: "active"
|
|
583
687
|
};
|
|
584
688
|
}
|
|
585
|
-
/**
|
|
586
|
-
|
|
689
|
+
/**
|
|
690
|
+
* Build the initial password-field key state.
|
|
691
|
+
*
|
|
692
|
+
* @param field - The password field to render
|
|
693
|
+
* @param styler - The styler used to render the view
|
|
694
|
+
* @param theme - The optional terminal theme
|
|
695
|
+
* @returns The initial immutable key state
|
|
696
|
+
*/
|
|
697
|
+
function createPasswordState(field, styler = (0, _orkestrel_console.createStyler)(), theme) {
|
|
587
698
|
return {
|
|
588
|
-
message:
|
|
589
|
-
mask:
|
|
590
|
-
|
|
591
|
-
|
|
699
|
+
message: sanitizeDisplayText(field.label ?? field.name),
|
|
700
|
+
mask: sanitizeDisplayText(field.mask ?? "*"),
|
|
701
|
+
styler,
|
|
702
|
+
theme: createPromptTheme(theme),
|
|
592
703
|
value: ""
|
|
593
704
|
};
|
|
594
705
|
}
|
|
595
|
-
/** Render a
|
|
706
|
+
/** Render a password-field key state as a styled view. */
|
|
596
707
|
function passwordView(state) {
|
|
597
|
-
const masked = state.mask.repeat(state.value.length);
|
|
598
|
-
|
|
599
|
-
return state.error === void 0 ? head : `${head}\n${errorLine(state.styler, state.error)}`;
|
|
708
|
+
const masked = state.styler.render(state.theme.roles.content, state.mask.repeat(state.value.length));
|
|
709
|
+
return `${promptHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.pointer, state.theme.icons.pointer)} ${masked}`;
|
|
600
710
|
}
|
|
601
711
|
/**
|
|
602
712
|
* Advance a password prompt by one {@link KeyEvent} — the pure `(state, key) → PromptStep<string>`
|
|
603
713
|
* reducer. Identical line-editing to {@link inputReduce} (printable extends, backspace shrinks,
|
|
604
|
-
* ctrl-u clears, ctrl-c cancels) but the view masks the value
|
|
605
|
-
* validator (no default fallback — a password has no echoed default).
|
|
714
|
+
* ctrl-u clears, ctrl-c cancels) but the view masks the value. Return produces the candidate value.
|
|
606
715
|
*/
|
|
607
716
|
function passwordReduce(state, key) {
|
|
608
717
|
if (key.ctrl && key.name === "c") return {
|
|
@@ -610,27 +719,12 @@ function passwordReduce(state, key) {
|
|
|
610
719
|
view: passwordView(state),
|
|
611
720
|
status: "cancel"
|
|
612
721
|
};
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
error: result
|
|
620
|
-
};
|
|
621
|
-
return {
|
|
622
|
-
state: next,
|
|
623
|
-
view: passwordView(next),
|
|
624
|
-
status: "active"
|
|
625
|
-
};
|
|
626
|
-
}
|
|
627
|
-
return {
|
|
628
|
-
state: clear,
|
|
629
|
-
view: `${submitHeader(state.styler, state.message)} ${state.styler.dim(state.mask.repeat(state.value.length))}`,
|
|
630
|
-
status: "submit",
|
|
631
|
-
value: state.value
|
|
632
|
-
};
|
|
633
|
-
}
|
|
722
|
+
if (key.name === "return") return {
|
|
723
|
+
state,
|
|
724
|
+
view: `${submitHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.hint, state.mask.repeat(state.value.length))}`,
|
|
725
|
+
status: "submit",
|
|
726
|
+
value: state.value
|
|
727
|
+
};
|
|
634
728
|
const value = editLine(state.value, key);
|
|
635
729
|
if (value === void 0) return {
|
|
636
730
|
state,
|
|
@@ -638,7 +732,7 @@ function passwordReduce(state, key) {
|
|
|
638
732
|
status: "active"
|
|
639
733
|
};
|
|
640
734
|
const next = {
|
|
641
|
-
...
|
|
735
|
+
...state,
|
|
642
736
|
value
|
|
643
737
|
};
|
|
644
738
|
return {
|
|
@@ -647,18 +741,29 @@ function passwordReduce(state, key) {
|
|
|
647
741
|
status: "active"
|
|
648
742
|
};
|
|
649
743
|
}
|
|
650
|
-
/**
|
|
651
|
-
|
|
744
|
+
/**
|
|
745
|
+
* Build the initial confirm-field key state.
|
|
746
|
+
*
|
|
747
|
+
* @param field - The confirm field to render
|
|
748
|
+
* @param styler - The styler used to render the view
|
|
749
|
+
* @param theme - The optional terminal theme
|
|
750
|
+
* @returns The initial immutable key state
|
|
751
|
+
*/
|
|
752
|
+
function createConfirmState(field, styler = (0, _orkestrel_console.createStyler)(), theme) {
|
|
652
753
|
return {
|
|
653
|
-
message:
|
|
654
|
-
default:
|
|
655
|
-
styler
|
|
754
|
+
message: sanitizeDisplayText(field.label ?? field.name),
|
|
755
|
+
default: field.default ?? false,
|
|
756
|
+
styler,
|
|
757
|
+
theme: createPromptTheme(theme)
|
|
656
758
|
};
|
|
657
759
|
}
|
|
658
|
-
/**
|
|
760
|
+
/**
|
|
761
|
+
* Render a confirm-field key state as a styled view. The selected role paints the default letter.
|
|
762
|
+
*/
|
|
659
763
|
function confirmView(state) {
|
|
660
|
-
const
|
|
661
|
-
|
|
764
|
+
const head = promptHeader(state.styler, state.theme, state.message);
|
|
765
|
+
const answer = state.default ? `${state.styler.render(state.theme.roles.selected, "Y")}${state.styler.render(state.theme.roles.hint, "/n")}` : `${state.styler.render(state.theme.roles.hint, "y/")}${state.styler.render(state.theme.roles.selected, "N")}`;
|
|
766
|
+
return `${head} ${state.styler.render(state.theme.roles.hint, "(")}${answer}${state.styler.render(state.theme.roles.hint, ")")}`;
|
|
662
767
|
}
|
|
663
768
|
/**
|
|
664
769
|
* Advance a confirm prompt by one {@link KeyEvent} — the pure `(state, key) → PromptStep<boolean>`
|
|
@@ -683,29 +788,37 @@ function confirmReduce(state, key) {
|
|
|
683
788
|
};
|
|
684
789
|
return {
|
|
685
790
|
state,
|
|
686
|
-
view: `${submitHeader(state.styler, state.message)} ${state.styler.
|
|
791
|
+
view: `${submitHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.hint, answer ? "yes" : "no")}`,
|
|
687
792
|
status: "submit",
|
|
688
793
|
value: answer
|
|
689
794
|
};
|
|
690
795
|
}
|
|
691
|
-
/**
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
796
|
+
/**
|
|
797
|
+
* Build the initial select-field key state.
|
|
798
|
+
*
|
|
799
|
+
* @param field - The select field to render
|
|
800
|
+
* @param styler - The styler used to render the view
|
|
801
|
+
* @param theme - The optional terminal theme
|
|
802
|
+
* @returns The initial immutable key state
|
|
803
|
+
*/
|
|
804
|
+
function createSelectState(field, styler = (0, _orkestrel_console.createStyler)(), theme) {
|
|
805
|
+
const choices = [...field.choices];
|
|
806
|
+
const index = choices.findIndex((choice) => choice.value === field.default);
|
|
695
807
|
return {
|
|
696
|
-
message:
|
|
808
|
+
message: sanitizeDisplayText(field.label ?? field.name),
|
|
697
809
|
choices,
|
|
698
|
-
styler
|
|
810
|
+
styler,
|
|
811
|
+
theme: createPromptTheme(theme),
|
|
699
812
|
focused: index >= 0 ? index : 0
|
|
700
813
|
};
|
|
701
814
|
}
|
|
702
|
-
/** Render a
|
|
815
|
+
/** Render a select-field key state as a multi-line styled view. */
|
|
703
816
|
function selectView(state) {
|
|
704
817
|
const lines = state.choices.map((choice, index) => {
|
|
705
818
|
const active = index === state.focused;
|
|
706
|
-
return `${active ? state.styler.
|
|
819
|
+
return `${active ? state.styler.render(state.theme.roles.pointer, state.theme.icons.pointer) : " "} ${active ? state.styler.render(state.theme.roles.selected, state.theme.icons.selected) : state.styler.render(state.theme.roles.muted, state.theme.icons.dot)} ${active ? state.styler.render(state.theme.roles.focus, choice.label) : state.styler.render(state.theme.roles.content, choice.label)}${choice.help === void 0 ? "" : ` ${state.styler.render(state.theme.roles.description, choice.help)}`}`;
|
|
707
820
|
});
|
|
708
|
-
return [promptHeader(state.styler, state.message), ...lines].join("\n");
|
|
821
|
+
return [promptHeader(state.styler, state.theme, state.message), ...lines].join("\n");
|
|
709
822
|
}
|
|
710
823
|
/**
|
|
711
824
|
* Advance a select prompt by one {@link KeyEvent} — the pure `(state, key) → PromptStep<string>`
|
|
@@ -752,7 +865,7 @@ function selectReduce(state, key) {
|
|
|
752
865
|
const value = choice?.value ?? "";
|
|
753
866
|
return {
|
|
754
867
|
state,
|
|
755
|
-
view: `${submitHeader(state.styler, state.message)} ${state.styler.
|
|
868
|
+
view: `${submitHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.hint, choice?.label ?? "")}`,
|
|
756
869
|
status: "submit",
|
|
757
870
|
value
|
|
758
871
|
};
|
|
@@ -763,44 +876,48 @@ function selectReduce(state, key) {
|
|
|
763
876
|
status: "active"
|
|
764
877
|
};
|
|
765
878
|
}
|
|
766
|
-
/**
|
|
767
|
-
|
|
768
|
-
|
|
879
|
+
/**
|
|
880
|
+
* Build the initial checkbox-field key state.
|
|
881
|
+
*
|
|
882
|
+
* @param field - The checkbox field to render
|
|
883
|
+
* @param styler - The styler used to render the view
|
|
884
|
+
* @param theme - The optional terminal theme
|
|
885
|
+
* @returns The initial immutable key state
|
|
886
|
+
*/
|
|
887
|
+
function createCheckboxState(field, styler = (0, _orkestrel_console.createStyler)(), theme) {
|
|
888
|
+
const choices = [...field.choices];
|
|
769
889
|
const checked = choices.reduce((indices, choice, index) => {
|
|
770
|
-
if (choice.
|
|
890
|
+
if (field.default?.includes(choice.value) === true) indices.push(index);
|
|
771
891
|
return indices;
|
|
772
892
|
}, []);
|
|
773
893
|
return {
|
|
774
|
-
message:
|
|
894
|
+
message: sanitizeDisplayText(field.label ?? field.name),
|
|
775
895
|
choices,
|
|
776
|
-
styler
|
|
896
|
+
styler,
|
|
897
|
+
theme: createPromptTheme(theme),
|
|
777
898
|
focused: 0,
|
|
778
|
-
checked
|
|
779
|
-
...options.min !== void 0 ? { min: options.min } : {},
|
|
780
|
-
...options.max !== void 0 ? { max: options.max } : {}
|
|
899
|
+
checked
|
|
781
900
|
};
|
|
782
901
|
}
|
|
783
|
-
/** Render a
|
|
902
|
+
/** Render a checkbox-field key state as a multi-line styled view. */
|
|
784
903
|
function checkboxView(state) {
|
|
785
904
|
const lines = state.choices.map((choice, index) => {
|
|
786
905
|
const active = index === state.focused;
|
|
787
906
|
const ticked = state.checked.includes(index);
|
|
788
|
-
return `${active ? state.styler.
|
|
907
|
+
return `${active ? state.styler.render(state.theme.roles.pointer, state.theme.icons.pointer) : " "} ${ticked ? state.styler.render(state.theme.roles.selected, state.theme.icons.checked) : state.styler.render(state.theme.roles.muted, state.theme.icons.unchecked)} ${active ? state.styler.render(state.theme.roles.focus, choice.label) : state.styler.render(state.theme.roles.content, choice.label)}${choice.help === void 0 ? "" : ` ${state.styler.render(state.theme.roles.description, choice.help)}`}`;
|
|
789
908
|
});
|
|
790
|
-
const summary = state.styler.
|
|
791
|
-
|
|
792
|
-
promptHeader(state.styler, state.message),
|
|
909
|
+
const summary = state.styler.render(state.theme.roles.hint, `${state.checked.length} selected`);
|
|
910
|
+
return [
|
|
911
|
+
promptHeader(state.styler, state.theme, state.message),
|
|
793
912
|
...lines,
|
|
794
913
|
summary
|
|
795
914
|
].join("\n");
|
|
796
|
-
return state.error === void 0 ? body : `${body}\n${errorLine(state.styler, state.error)}`;
|
|
797
915
|
}
|
|
798
916
|
/**
|
|
799
917
|
* Advance a checkbox prompt by one {@link KeyEvent} — the pure
|
|
800
918
|
* `(state, key) → PromptStep<readonly string[]>` reducer. `up` / `down` (and `k` / `j`) move the
|
|
801
919
|
* focus (wrapping); `space` toggles the focused index in the checked set; return submits the
|
|
802
|
-
* checked values in
|
|
803
|
-
* the reason in the view); ctrl-c cancels.
|
|
920
|
+
* checked values in choice order; ctrl-c cancels. The form applies selection-count rules.
|
|
804
921
|
*/
|
|
805
922
|
function checkboxReduce(state, key) {
|
|
806
923
|
if (key.ctrl && key.name === "c") return {
|
|
@@ -809,10 +926,9 @@ function checkboxReduce(state, key) {
|
|
|
809
926
|
status: "cancel"
|
|
810
927
|
};
|
|
811
928
|
const count = state.choices.length;
|
|
812
|
-
const { error: _error, ...clear } = state;
|
|
813
929
|
if ((key.name === "up" || key.name === "k") && count > 0) {
|
|
814
930
|
const next = {
|
|
815
|
-
...
|
|
931
|
+
...state,
|
|
816
932
|
focused: (state.focused - 1 + count) % count
|
|
817
933
|
};
|
|
818
934
|
return {
|
|
@@ -823,7 +939,7 @@ function checkboxReduce(state, key) {
|
|
|
823
939
|
}
|
|
824
940
|
if ((key.name === "down" || key.name === "j") && count > 0) {
|
|
825
941
|
const next = {
|
|
826
|
-
...
|
|
942
|
+
...state,
|
|
827
943
|
focused: (state.focused + 1) % count
|
|
828
944
|
};
|
|
829
945
|
return {
|
|
@@ -835,7 +951,7 @@ function checkboxReduce(state, key) {
|
|
|
835
951
|
if (key.name === "space" && count > 0) {
|
|
836
952
|
const checked = toggleIndex(state.checked, state.focused);
|
|
837
953
|
const next = {
|
|
838
|
-
...
|
|
954
|
+
...state,
|
|
839
955
|
checked
|
|
840
956
|
};
|
|
841
957
|
return {
|
|
@@ -845,24 +961,12 @@ function checkboxReduce(state, key) {
|
|
|
845
961
|
};
|
|
846
962
|
}
|
|
847
963
|
if (key.name === "return") {
|
|
848
|
-
const error = gateSelection(state.checked.length, state.min, state.max);
|
|
849
|
-
if (error !== void 0) {
|
|
850
|
-
const next = {
|
|
851
|
-
...state,
|
|
852
|
-
error
|
|
853
|
-
};
|
|
854
|
-
return {
|
|
855
|
-
state: next,
|
|
856
|
-
view: checkboxView(next),
|
|
857
|
-
status: "active"
|
|
858
|
-
};
|
|
859
|
-
}
|
|
860
964
|
const ordered = [...state.checked].sort((a, b) => a - b);
|
|
861
965
|
const values = ordered.map((index) => state.choices[index]?.value).filter((value) => value !== void 0);
|
|
862
|
-
const summary = ordered.map((index) => state.choices[index]?.
|
|
966
|
+
const summary = ordered.map((index) => state.choices[index]?.label).filter((name) => name !== void 0).join(", ");
|
|
863
967
|
return {
|
|
864
|
-
state
|
|
865
|
-
view: `${submitHeader(state.styler, state.message)} ${state.styler.
|
|
968
|
+
state,
|
|
969
|
+
view: `${submitHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.hint, summary)}`,
|
|
866
970
|
status: "submit",
|
|
867
971
|
value: values
|
|
868
972
|
};
|
|
@@ -877,33 +981,37 @@ function checkboxReduce(state, key) {
|
|
|
877
981
|
function toggleIndex(indices, index) {
|
|
878
982
|
return indices.includes(index) ? indices.filter((i) => i !== index) : [...indices, index];
|
|
879
983
|
}
|
|
880
|
-
/**
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
984
|
+
/**
|
|
985
|
+
* Build the initial editor-field key state.
|
|
986
|
+
*
|
|
987
|
+
* @param field - The editor field to render
|
|
988
|
+
* @param styler - The styler used to render the view
|
|
989
|
+
* @param theme - The optional terminal theme
|
|
990
|
+
* @returns The initial immutable key state
|
|
991
|
+
*/
|
|
992
|
+
function createEditorState(field, styler = (0, _orkestrel_console.createStyler)(), theme) {
|
|
887
993
|
return {
|
|
888
|
-
message:
|
|
889
|
-
default:
|
|
890
|
-
|
|
891
|
-
|
|
994
|
+
message: sanitizeDisplayText(field.label ?? field.name),
|
|
995
|
+
default: field.default ?? "",
|
|
996
|
+
styler,
|
|
997
|
+
theme: createPromptTheme(theme),
|
|
892
998
|
lines: [],
|
|
893
999
|
current: ""
|
|
894
1000
|
};
|
|
895
1001
|
}
|
|
896
|
-
/**
|
|
1002
|
+
/**
|
|
1003
|
+
* Render an editor-field key state as a multi-line styled view with its finish hint.
|
|
1004
|
+
*/
|
|
897
1005
|
function editorView(state) {
|
|
898
|
-
const
|
|
899
|
-
|
|
1006
|
+
const head = hintedHeader(state.styler, state.theme, state.message, "(Ctrl+D to finish)");
|
|
1007
|
+
const pointer = state.styler.render(state.theme.roles.pointer, state.theme.icons.pointer);
|
|
1008
|
+
return [head, ...[...state.lines.map((line) => state.styler.render(state.theme.roles.content, line)), `${pointer} ${state.styler.render(state.theme.roles.content, state.current)}`]].join("\n");
|
|
900
1009
|
}
|
|
901
1010
|
/**
|
|
902
1011
|
* Advance an editor prompt by one {@link KeyEvent} — the pure `(state, key) → PromptStep<string>`
|
|
903
1012
|
* reducer. Printable characters extend the current line; backspace shrinks it; return commits the
|
|
904
1013
|
* current line and starts a fresh one; ctrl-d FINISHES (joining all lines, falling back to the
|
|
905
|
-
* default when empty)
|
|
906
|
-
* the error.
|
|
1014
|
+
* default when empty); ctrl-c cancels. The form validates the candidate after the driver fills it.
|
|
907
1015
|
*/
|
|
908
1016
|
function editorReduce(state, key) {
|
|
909
1017
|
if (key.ctrl && key.name === "c") return {
|
|
@@ -911,33 +1019,20 @@ function editorReduce(state, key) {
|
|
|
911
1019
|
view: editorView(state),
|
|
912
1020
|
status: "cancel"
|
|
913
1021
|
};
|
|
914
|
-
const { error: _error, ...clear } = state;
|
|
915
1022
|
if (key.ctrl && key.name === "d") {
|
|
916
1023
|
const lines = state.current.length > 0 ? [...state.lines, state.current] : state.lines;
|
|
917
1024
|
const joined = lines.join("\n");
|
|
918
1025
|
const answer = joined.length > 0 ? joined : state.default;
|
|
919
|
-
const result = state.validator(answer);
|
|
920
|
-
if (result !== true) {
|
|
921
|
-
const next = {
|
|
922
|
-
...state,
|
|
923
|
-
error: result
|
|
924
|
-
};
|
|
925
|
-
return {
|
|
926
|
-
state: next,
|
|
927
|
-
view: editorView(next),
|
|
928
|
-
status: "active"
|
|
929
|
-
};
|
|
930
|
-
}
|
|
931
1026
|
return {
|
|
932
|
-
state
|
|
933
|
-
view: `${submitHeader(state.styler, state.message)} ${state.styler.
|
|
1027
|
+
state,
|
|
1028
|
+
view: `${submitHeader(state.styler, state.theme, state.message)} ${state.styler.render(state.theme.roles.hint, `${String(lines.length)} line${lines.length === 1 ? "" : "s"}`)}`,
|
|
934
1029
|
status: "submit",
|
|
935
1030
|
value: answer
|
|
936
1031
|
};
|
|
937
1032
|
}
|
|
938
1033
|
if (key.name === "return") {
|
|
939
1034
|
const next = {
|
|
940
|
-
...
|
|
1035
|
+
...state,
|
|
941
1036
|
lines: [...state.lines, state.current],
|
|
942
1037
|
current: ""
|
|
943
1038
|
};
|
|
@@ -954,7 +1049,7 @@ function editorReduce(state, key) {
|
|
|
954
1049
|
status: "active"
|
|
955
1050
|
};
|
|
956
1051
|
const next = {
|
|
957
|
-
...
|
|
1052
|
+
...state,
|
|
958
1053
|
current
|
|
959
1054
|
};
|
|
960
1055
|
return {
|
|
@@ -976,205 +1071,6 @@ function editLine(value, key) {
|
|
|
976
1071
|
if (!key.ctrl && !key.meta && [...key.name].length === 1 && isPrintable(key.name)) return `${value}${key.sequence}`;
|
|
977
1072
|
}
|
|
978
1073
|
/**
|
|
979
|
-
* Produce the WIRE-SAFE form of a prompt's options — the {@link PendingPrompt.options} a broker
|
|
980
|
-
* serializes over SSE. Drops everything that cannot cross the wire (a `styler`, and any
|
|
981
|
-
* function-valued `validate` rule), while KEEPING the declarative data a remote client needs to
|
|
982
|
-
* reconstruct the prompt: the {@link ValidationRules} as data, plus `message` / `choices` /
|
|
983
|
-
* `default` / `mask` / `min` / `max`. This is WHY T-a's validation is rules-as-data: a `Validator`
|
|
984
|
-
* FUNCTION can't be serialized, but its declarative rules can — the client rebuilds the validator
|
|
985
|
-
* from them via {@link resolveValidation}.
|
|
986
|
-
*
|
|
987
|
-
* @remarks
|
|
988
|
-
* - **Functions are dropped.** A top-level function value (e.g. a bare-`Validator` `validate`) is
|
|
989
|
-
* omitted entirely; the `styler` (a fluent function-bearing object) is dropped by key.
|
|
990
|
-
* - **`validate` rules are flattened.** A {@link ValidationRules} bag is copied with each
|
|
991
|
-
* function rule replaced by `true` (the rule's INTENT survives as the built-in check; its
|
|
992
|
-
* custom function does not). A bare-function `validate` is dropped (no declarative form to keep).
|
|
993
|
-
* - **`choices` are function-stripped.** Each choice keeps its plain fields (`name` / `value` /
|
|
994
|
-
* `description` / `checked`); a bare string passes through.
|
|
995
|
-
*
|
|
996
|
-
* @param options - The raw prompt options bag (may hold functions / a styler)
|
|
997
|
-
* @returns A JSON-safe options record — only serializable, declarative data
|
|
998
|
-
*/
|
|
999
|
-
function serializePromptOptions(options) {
|
|
1000
|
-
const result = {};
|
|
1001
|
-
for (const [key, value] of Object.entries(options)) {
|
|
1002
|
-
if (key === "styler" || typeof value === "function") continue;
|
|
1003
|
-
if (key === "validate") {
|
|
1004
|
-
const rules = serializeValidationRules(value);
|
|
1005
|
-
if (rules !== void 0) result[key] = rules;
|
|
1006
|
-
continue;
|
|
1007
|
-
}
|
|
1008
|
-
if (key === "choices") {
|
|
1009
|
-
result[key] = serializeChoices(value);
|
|
1010
|
-
continue;
|
|
1011
|
-
}
|
|
1012
|
-
result[key] = value;
|
|
1013
|
-
}
|
|
1014
|
-
return result;
|
|
1015
|
-
}
|
|
1016
|
-
/**
|
|
1017
|
-
* Flatten a `validate` option to its wire-safe {@link ValidationRules} DATA — a function rule
|
|
1018
|
-
* becomes `true` (its built-in check survives; its body cannot cross the wire). A bare-function
|
|
1019
|
-
* `validate` (no rules object) has no declarative form, so it yields `undefined` (dropped).
|
|
1020
|
-
*/
|
|
1021
|
-
function serializeValidationRules(validate) {
|
|
1022
|
-
if (!(0, _orkestrel_contract.isRecord)(validate)) return void 0;
|
|
1023
|
-
const rules = {};
|
|
1024
|
-
for (const [rule, value] of Object.entries(validate)) rules[rule] = typeof value === "function" ? true : value;
|
|
1025
|
-
return rules;
|
|
1026
|
-
}
|
|
1027
|
-
/** Strip functions from a `choices` option — each choice keeps its plain fields; a bare string passes through. */
|
|
1028
|
-
function serializeChoices(choices) {
|
|
1029
|
-
if (!Array.isArray(choices)) return [];
|
|
1030
|
-
return choices.map((choice) => {
|
|
1031
|
-
if ((0, _orkestrel_contract.isString)(choice)) return choice;
|
|
1032
|
-
if (!(0, _orkestrel_contract.isRecord)(choice)) return choice;
|
|
1033
|
-
const normalized = {};
|
|
1034
|
-
for (const [key, value] of Object.entries(choice)) if (typeof value !== "function") normalized[key] = value;
|
|
1035
|
-
return normalized;
|
|
1036
|
-
});
|
|
1037
|
-
}
|
|
1038
|
-
/**
|
|
1039
|
-
* Rebuild a wire-decoded `validate` payload into a {@link ValidationRules} bag — the inverse of
|
|
1040
|
-
* {@link serializeValidationRules}. Keeps only the primitive rule values (`boolean` / `number` /
|
|
1041
|
-
* `string`) a serialized prompt could carry; an empty / non-record / all-dropped payload yields
|
|
1042
|
-
* `undefined` (no rules to apply). The client feeds the result back through {@link resolveValidation}.
|
|
1043
|
-
*/
|
|
1044
|
-
function reconstructValidationRules(value) {
|
|
1045
|
-
if (!(0, _orkestrel_contract.isRecord)(value)) return void 0;
|
|
1046
|
-
const rules = {};
|
|
1047
|
-
let count = 0;
|
|
1048
|
-
for (const [rule, item] of Object.entries(value)) {
|
|
1049
|
-
if (rule === "pattern") continue;
|
|
1050
|
-
if ((0, _orkestrel_contract.isBoolean)(item) || (0, _orkestrel_contract.isNumber)(item) || (0, _orkestrel_contract.isString)(item)) {
|
|
1051
|
-
rules[rule] = item;
|
|
1052
|
-
count += 1;
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
if (count === 0) return void 0;
|
|
1056
|
-
return rules;
|
|
1057
|
-
}
|
|
1058
|
-
/** Read one option by key, narrowed by `guard` — `undefined` when absent or off-shape (§14, never an `as`). */
|
|
1059
|
-
function resolveOption(options, key, guard) {
|
|
1060
|
-
const value = options[key];
|
|
1061
|
-
return guard(value) ? value : void 0;
|
|
1062
|
-
}
|
|
1063
|
-
/** Narrow an unknown value to a {@link PromptChoice} — the `recordOf` shape inlined so no non-exported member lingers (§5). */
|
|
1064
|
-
function isPromptChoice(value) {
|
|
1065
|
-
return (0, _orkestrel_contract.recordOf)({
|
|
1066
|
-
name: _orkestrel_contract.isString,
|
|
1067
|
-
value: _orkestrel_contract.isString,
|
|
1068
|
-
description: _orkestrel_contract.isString
|
|
1069
|
-
}, ["description"])(value);
|
|
1070
|
-
}
|
|
1071
|
-
/** Narrow an unknown value to a {@link CheckboxChoice} — the `recordOf` shape inlined so no non-exported member lingers (§5). */
|
|
1072
|
-
function isCheckboxChoice(value) {
|
|
1073
|
-
return (0, _orkestrel_contract.recordOf)({
|
|
1074
|
-
name: _orkestrel_contract.isString,
|
|
1075
|
-
value: _orkestrel_contract.isString,
|
|
1076
|
-
description: _orkestrel_contract.isString,
|
|
1077
|
-
checked: _orkestrel_contract.isBoolean
|
|
1078
|
-
}, ["description", "checked"])(value);
|
|
1079
|
-
}
|
|
1080
|
-
/** Read a `choices` option as a list of bare strings / full choices — each element narrowed by `guard`, off-shape elements stringified. */
|
|
1081
|
-
function resolveChoices(options, guard) {
|
|
1082
|
-
const choices = options.choices;
|
|
1083
|
-
if (!Array.isArray(choices)) return [];
|
|
1084
|
-
return choices.map((choice) => {
|
|
1085
|
-
if ((0, _orkestrel_contract.isString)(choice)) return choice;
|
|
1086
|
-
if (guard(choice)) return choice;
|
|
1087
|
-
return String(choice);
|
|
1088
|
-
});
|
|
1089
|
-
}
|
|
1090
|
-
/**
|
|
1091
|
-
* Sanitize a list of resolved choices' human-readable labels (`name` + `description`) with
|
|
1092
|
-
* {@link stripControls} — shared by the `select` and `checkbox` branches of
|
|
1093
|
-
* {@link dispatchPendingPrompt} so a remote-supplied choice can never inject raw control bytes
|
|
1094
|
-
* into the local terminal's rendered view.
|
|
1095
|
-
*
|
|
1096
|
-
* @param choices - The resolved choices (bare strings or full {@link PromptChoice} /
|
|
1097
|
-
* {@link CheckboxChoice} objects) as returned by {@link resolveChoices}
|
|
1098
|
-
* @returns The same choices with every `name` / `description` control-stripped
|
|
1099
|
-
*/
|
|
1100
|
-
function sanitizeChoiceLabels(choices) {
|
|
1101
|
-
return choices.map((choice) => {
|
|
1102
|
-
if ((0, _orkestrel_contract.isString)(choice)) return (0, _orkestrel_console.stripControls)(choice);
|
|
1103
|
-
return {
|
|
1104
|
-
...choice,
|
|
1105
|
-
name: (0, _orkestrel_console.stripControls)(choice.name),
|
|
1106
|
-
...choice.description !== void 0 ? { description: (0, _orkestrel_console.stripControls)(choice.description) } : {}
|
|
1107
|
-
};
|
|
1108
|
-
});
|
|
1109
|
-
}
|
|
1110
|
-
/**
|
|
1111
|
-
* Dispatch a {@link PendingPrompt} to the matching {@link PromptFormInterface} method — the bridge
|
|
1112
|
-
* step a {@link PromptClient} runs to drive a LOCAL terminal with a prompt issued elsewhere.
|
|
1113
|
-
* Reconstructs typed options from the wire-safe {@link PendingPrompt.options} (every field
|
|
1114
|
-
* §14-narrowed, never an `as`; the validator rebuilt from rules via {@link reconstructValidationRules}),
|
|
1115
|
-
* then calls the matching prompt form and returns its resolved value.
|
|
1116
|
-
*
|
|
1117
|
-
* @param terminal - The local {@link PromptFormInterface} to drive
|
|
1118
|
-
* @param pending - The decoded pending prompt to dispatch
|
|
1119
|
-
* @returns The prompt's resolved value (a `string` / `boolean` / `readonly string[]` per form)
|
|
1120
|
-
*/
|
|
1121
|
-
function dispatchPendingPrompt(terminal, pending) {
|
|
1122
|
-
const options = pending.options;
|
|
1123
|
-
const validate = reconstructValidationRules(options.validate);
|
|
1124
|
-
const message = (0, _orkestrel_console.stripControls)(pending.message);
|
|
1125
|
-
switch (pending.form) {
|
|
1126
|
-
case "input": {
|
|
1127
|
-
const value = resolveOption(options, "default", _orkestrel_contract.isString);
|
|
1128
|
-
return terminal.input({
|
|
1129
|
-
message,
|
|
1130
|
-
...value !== void 0 ? { default: (0, _orkestrel_console.stripControls)(value) } : {},
|
|
1131
|
-
...validate !== void 0 ? { validate } : {}
|
|
1132
|
-
});
|
|
1133
|
-
}
|
|
1134
|
-
case "password": {
|
|
1135
|
-
const value = resolveOption(options, "mask", _orkestrel_contract.isString);
|
|
1136
|
-
return terminal.password({
|
|
1137
|
-
message,
|
|
1138
|
-
...value !== void 0 ? { mask: (0, _orkestrel_console.stripControls)(value) } : {},
|
|
1139
|
-
...validate !== void 0 ? { validate } : {}
|
|
1140
|
-
});
|
|
1141
|
-
}
|
|
1142
|
-
case "confirm": {
|
|
1143
|
-
const value = resolveOption(options, "default", _orkestrel_contract.isBoolean);
|
|
1144
|
-
return terminal.confirm({
|
|
1145
|
-
message,
|
|
1146
|
-
...value !== void 0 ? { default: value } : {}
|
|
1147
|
-
});
|
|
1148
|
-
}
|
|
1149
|
-
case "select": {
|
|
1150
|
-
const value = resolveOption(options, "default", _orkestrel_contract.isString);
|
|
1151
|
-
return terminal.select({
|
|
1152
|
-
message,
|
|
1153
|
-
choices: sanitizeChoiceLabels(resolveChoices(options, isPromptChoice)),
|
|
1154
|
-
...value !== void 0 ? { default: (0, _orkestrel_console.stripControls)(value) } : {}
|
|
1155
|
-
});
|
|
1156
|
-
}
|
|
1157
|
-
case "checkbox": {
|
|
1158
|
-
const min = resolveOption(options, "min", _orkestrel_contract.isNumber);
|
|
1159
|
-
const max = resolveOption(options, "max", _orkestrel_contract.isNumber);
|
|
1160
|
-
return terminal.checkbox({
|
|
1161
|
-
message,
|
|
1162
|
-
choices: sanitizeChoiceLabels(resolveChoices(options, isCheckboxChoice)),
|
|
1163
|
-
...min !== void 0 ? { min } : {},
|
|
1164
|
-
...max !== void 0 ? { max } : {}
|
|
1165
|
-
});
|
|
1166
|
-
}
|
|
1167
|
-
case "editor": {
|
|
1168
|
-
const value = resolveOption(options, "default", _orkestrel_contract.isString);
|
|
1169
|
-
return terminal.editor({
|
|
1170
|
-
message,
|
|
1171
|
-
...value !== void 0 ? { default: (0, _orkestrel_console.stripControls)(value) } : {},
|
|
1172
|
-
...validate !== void 0 ? { validate } : {}
|
|
1173
|
-
});
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
/**
|
|
1178
1074
|
* The default {@link import('./types.js').TimerHandler} — a thin host `setTimeout` / `clearTimeout`
|
|
1179
1075
|
* wrapper that arms `callback` after `ms` and returns a {@link TimerCancel}. The deadline seam
|
|
1180
1076
|
* behind both the {@link import('./Prompt.js').Prompt} broker (its expiry) and the
|
|
@@ -1198,19 +1094,6 @@ function isAbortError(error) {
|
|
|
1198
1094
|
return (error instanceof DOMException || error instanceof Error) && error.name === "AbortError";
|
|
1199
1095
|
}
|
|
1200
1096
|
/**
|
|
1201
|
-
* Parse a JSON wire string TOTAL — a malformed / empty payload yields `undefined` (the caller's
|
|
1202
|
-
* guard then rejects it), never a throw. The {@link import('./PromptClient.js').PromptClient}
|
|
1203
|
-
* decodes every SSE `data` field through this before §14-narrowing it.
|
|
1204
|
-
*/
|
|
1205
|
-
function parseWireJSON(text) {
|
|
1206
|
-
if (text.length === 0) return void 0;
|
|
1207
|
-
try {
|
|
1208
|
-
return JSON.parse(text);
|
|
1209
|
-
} catch {
|
|
1210
|
-
return;
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
/**
|
|
1214
1097
|
* Whether `url` is an INSECURE remote endpoint — a plain `http://` URL whose host is NOT a
|
|
1215
1098
|
* loopback address. Pure string parsing (no `URL` global), so it stays total on malformed input.
|
|
1216
1099
|
*
|
|
@@ -1238,12 +1121,12 @@ function isInsecureRemote(url) {
|
|
|
1238
1121
|
const hostname = host.startsWith("[") ? host.slice(0, host.indexOf("]") + 1) : host.split(":")[0] ?? "";
|
|
1239
1122
|
return hostname !== "localhost" && hostname !== "127.0.0.1" && hostname !== "[::1]";
|
|
1240
1123
|
}
|
|
1241
|
-
/** Serialize a parked {@link
|
|
1242
|
-
function serializePending(
|
|
1124
|
+
/** Serialize a parked {@link PendingForm} into a {@link WireEvent}. */
|
|
1125
|
+
function serializePending(form) {
|
|
1243
1126
|
return {
|
|
1244
1127
|
event: "pending",
|
|
1245
|
-
data: JSON.stringify(
|
|
1246
|
-
id:
|
|
1128
|
+
data: JSON.stringify(form),
|
|
1129
|
+
id: form.id
|
|
1247
1130
|
};
|
|
1248
1131
|
}
|
|
1249
1132
|
/** Serialize a parked prompt's expiry into a {@link WireEvent} — event `'expire'`, `data` the JSON-stringified `{ id }` payload. */
|
|
@@ -1260,44 +1143,24 @@ function serializeShutdown() {
|
|
|
1260
1143
|
data: ""
|
|
1261
1144
|
};
|
|
1262
1145
|
}
|
|
1263
|
-
/** Narrow an unknown wire payload to an answer POST body — a non-empty `id` string plus a `value` key (of any shape) present. */
|
|
1264
|
-
function isAnswerPayload(value) {
|
|
1265
|
-
return (0, _orkestrel_contract.isRecord)(value) && (0, _orkestrel_contract.isNonEmptyString)(value.id) && "value" in value;
|
|
1266
|
-
}
|
|
1267
1146
|
//#endregion
|
|
1268
1147
|
//#region src/core/Prompt.ts
|
|
1269
1148
|
/**
|
|
1270
|
-
* The headless
|
|
1271
|
-
*
|
|
1272
|
-
* rejects on timeout / teardown). The tri-surface's headless arm: there is no terminal here, so a
|
|
1273
|
-
* transport forwards each `pending` event to whoever can answer, and {@link answer} resolves the
|
|
1274
|
-
* parked Promise — for environments where direct user access is unavailable (a headless server on
|
|
1275
|
-
* stdio, a browser with no TTY, a prompt issued on one machine answered on another).
|
|
1149
|
+
* The headless form broker. It parks live forms, exposes their serialized schemas, and applies
|
|
1150
|
+
* remote answers to the authoritative form.
|
|
1276
1151
|
*
|
|
1277
1152
|
* @remarks
|
|
1278
|
-
*
|
|
1279
|
-
*
|
|
1280
|
-
*
|
|
1281
|
-
* ({@link serializePromptOptions}) so a transport can forward them as-is.
|
|
1282
|
-
* - **Answer validates + type-checks.** {@link answer} runs the prompt's per-form gate: it
|
|
1283
|
-
* type-checks `value` to the form (string / boolean / string[]) AND, for the text forms, runs
|
|
1284
|
-
* the validator resolved from the original `validate` rules. A rejected answer returns `false`
|
|
1285
|
-
* and the prompt stays `pending`; an accepted answer resolves the Promise, emits `answer`, and
|
|
1286
|
-
* removes the prompt.
|
|
1287
|
-
* - **Timeout → expire → reject.** An unanswered prompt expires after `timeout` ms (via the
|
|
1288
|
-
* INJECTED timer): `expire` fires and the parked Promise rejects with a {@link TerminalError}
|
|
1289
|
-
* (`code: 'EXPIRE'`). {@link destroy} expires every still-pending prompt the same way.
|
|
1290
|
-
* - **Deterministic.** The timer is injectable ({@link import('./types.js').TimerHandler}); a test
|
|
1291
|
-
* supplies one that fires on demand, so expiry is driven without real time.
|
|
1153
|
+
* A parked record carries one call to `serializeForm`. A failed fill or submit leaves the record
|
|
1154
|
+
* parked for another answer. A successful submit settles the form once, emits `answer`, and removes
|
|
1155
|
+
* the record. Timeout and teardown abandon every unsettled form through its own `destroy` method.
|
|
1292
1156
|
*
|
|
1293
1157
|
* @example
|
|
1294
1158
|
* ```ts
|
|
1295
|
-
* const
|
|
1296
|
-
* prompt
|
|
1297
|
-
*
|
|
1298
|
-
*
|
|
1299
|
-
*
|
|
1300
|
-
* prompt.answer(id, 'Ada') // resolves the awaited input() above
|
|
1159
|
+
* const form = createForm({ fields: [{ control: 'text', name: 'name' }] })
|
|
1160
|
+
* const prompt = createPrompt()
|
|
1161
|
+
* const id = prompt.park(form)
|
|
1162
|
+
* prompt.answer(id, { name: 'Ada' })
|
|
1163
|
+
* await form.answer // { name: 'Ada' }
|
|
1301
1164
|
* ```
|
|
1302
1165
|
*/
|
|
1303
1166
|
var Prompt = class {
|
|
@@ -1323,211 +1186,164 @@ var Prompt = class {
|
|
|
1323
1186
|
return this.#parked.size;
|
|
1324
1187
|
}
|
|
1325
1188
|
pending(id) {
|
|
1326
|
-
if (id !== void 0) return this.#parked.get(id)?.
|
|
1327
|
-
const
|
|
1328
|
-
for (const parked of this.#parked.values())
|
|
1329
|
-
return
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1189
|
+
if (id !== void 0) return this.#parked.get(id)?.pending;
|
|
1190
|
+
const forms = [];
|
|
1191
|
+
for (const parked of this.#parked.values()) forms.push(parked.pending);
|
|
1192
|
+
return forms;
|
|
1193
|
+
}
|
|
1194
|
+
park(form, request) {
|
|
1195
|
+
if (this.#destroyed) {
|
|
1196
|
+
form.destroy();
|
|
1197
|
+
throw new TerminalError("EXPIRE", "The broker has been destroyed");
|
|
1198
|
+
}
|
|
1199
|
+
if (this.#cap !== void 0 && this.#parked.size >= this.#cap) {
|
|
1200
|
+
form.destroy();
|
|
1201
|
+
throw new TerminalError("LIMIT", `The parked-form cap (${String(this.#cap)}) was reached`, { cap: this.#cap });
|
|
1202
|
+
}
|
|
1203
|
+
const id = crypto.randomUUID();
|
|
1204
|
+
const pending = {
|
|
1205
|
+
id,
|
|
1206
|
+
schema: (0, _orkestrel_form.serializeForm)(form.schema),
|
|
1207
|
+
status: "pending",
|
|
1208
|
+
time: Date.now(),
|
|
1209
|
+
...request?.from !== void 0 ? { from: request.from } : {},
|
|
1210
|
+
...request?.to !== void 0 ? { to: request.to } : {}
|
|
1341
1211
|
};
|
|
1342
|
-
|
|
1343
|
-
this.#
|
|
1344
|
-
|
|
1212
|
+
const cancel = this.#timer(() => this.#expire(id), this.#timeout);
|
|
1213
|
+
this.#parked.set(id, {
|
|
1214
|
+
form,
|
|
1215
|
+
pending,
|
|
1216
|
+
cancel
|
|
1217
|
+
});
|
|
1218
|
+
this.#emitter.emit("pending", pending);
|
|
1219
|
+
return id;
|
|
1220
|
+
}
|
|
1221
|
+
answer(id, values) {
|
|
1222
|
+
const outcome = (0, _orkestrel_contract.attempt)(() => this.#answer(id, values));
|
|
1223
|
+
if (outcome.success) return outcome.value;
|
|
1345
1224
|
return {
|
|
1346
|
-
success:
|
|
1347
|
-
|
|
1225
|
+
success: false,
|
|
1226
|
+
error: {
|
|
1227
|
+
reason: "rejected",
|
|
1228
|
+
errors: [{
|
|
1229
|
+
field: "form",
|
|
1230
|
+
message: "The form rejected the answer"
|
|
1231
|
+
}]
|
|
1232
|
+
}
|
|
1348
1233
|
};
|
|
1349
1234
|
}
|
|
1350
|
-
park(request) {
|
|
1351
|
-
const gate = this.#gate(request.form, request.options);
|
|
1352
|
-
return this.#park(request.form, request.options.message, request.options, gate, request.from, request.to);
|
|
1353
|
-
}
|
|
1354
|
-
input(options) {
|
|
1355
|
-
return this.#park("input", options.message, options, this.#gate("input", options)).value;
|
|
1356
|
-
}
|
|
1357
|
-
password(options) {
|
|
1358
|
-
return this.#park("password", options.message, options, this.#gate("password", options)).value;
|
|
1359
|
-
}
|
|
1360
|
-
confirm(options) {
|
|
1361
|
-
return this.#park("confirm", options.message, options, this.#gate("confirm", options)).value;
|
|
1362
|
-
}
|
|
1363
|
-
select(options) {
|
|
1364
|
-
return this.#park("select", options.message, options, this.#gate("select", options)).value;
|
|
1365
|
-
}
|
|
1366
|
-
checkbox(options) {
|
|
1367
|
-
return this.#park("checkbox", options.message, options, this.#gate("checkbox", options)).value;
|
|
1368
|
-
}
|
|
1369
|
-
editor(options) {
|
|
1370
|
-
return this.#park("editor", options.message, options, this.#gate("editor", options)).value;
|
|
1371
|
-
}
|
|
1372
1235
|
destroy() {
|
|
1373
1236
|
if (this.#destroyed) return;
|
|
1374
1237
|
this.#destroyed = true;
|
|
1375
1238
|
for (const id of [...this.#parked.keys()]) this.#expire(id);
|
|
1376
|
-
this.#parked.clear();
|
|
1377
1239
|
this.#emitter.destroy();
|
|
1378
1240
|
}
|
|
1379
|
-
#
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
if (this.#cap !== void 0 && this.#parked.size >= this.#cap) return {
|
|
1385
|
-
id: crypto.randomUUID(),
|
|
1386
|
-
value: Promise.reject(new TerminalError("LIMIT", `parked-prompt cap (${this.#cap}) reached`, { cap: this.#cap }))
|
|
1387
|
-
};
|
|
1388
|
-
const id = crypto.randomUUID();
|
|
1389
|
-
const prompt = {
|
|
1390
|
-
id,
|
|
1391
|
-
form,
|
|
1392
|
-
message,
|
|
1393
|
-
options: serializePromptOptions(options),
|
|
1394
|
-
status: "pending",
|
|
1395
|
-
time: Date.now(),
|
|
1396
|
-
...from !== void 0 ? { from } : {},
|
|
1397
|
-
...to !== void 0 ? { to } : {}
|
|
1398
|
-
};
|
|
1399
|
-
return {
|
|
1400
|
-
id,
|
|
1401
|
-
value: new Promise((resolve, reject) => {
|
|
1402
|
-
const cancel = this.#timer(() => this.#expire(id), this.#timeout);
|
|
1403
|
-
const respond = this.#createRespond(id, gate, resolve);
|
|
1404
|
-
const expire = this.#createExpire(id, reject);
|
|
1405
|
-
this.#parked.set(id, {
|
|
1406
|
-
prompt,
|
|
1407
|
-
respond,
|
|
1408
|
-
expire,
|
|
1409
|
-
cancel
|
|
1410
|
-
});
|
|
1411
|
-
this.#emitter.emit("pending", prompt);
|
|
1412
|
-
})
|
|
1413
|
-
};
|
|
1414
|
-
}
|
|
1415
|
-
#createRespond(id, gate, resolve) {
|
|
1416
|
-
return (answer) => {
|
|
1417
|
-
const accepted = gate(answer);
|
|
1418
|
-
if (accepted === void 0) return void 0;
|
|
1419
|
-
const current = this.#parked.get(id);
|
|
1420
|
-
if (current !== void 0) this.#parked.set(id, {
|
|
1421
|
-
...current,
|
|
1422
|
-
prompt: {
|
|
1423
|
-
...current.prompt,
|
|
1424
|
-
status: "answered"
|
|
1425
|
-
}
|
|
1426
|
-
});
|
|
1427
|
-
resolve(accepted);
|
|
1428
|
-
return accepted;
|
|
1241
|
+
#answer(id, values) {
|
|
1242
|
+
const parked = this.#parked.get(id);
|
|
1243
|
+
if (parked === void 0 || parked.pending.status !== "pending") return {
|
|
1244
|
+
success: false,
|
|
1245
|
+
error: { reason: "unknown" }
|
|
1429
1246
|
};
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
case "input":
|
|
1437
|
-
case "password":
|
|
1438
|
-
case "editor": {
|
|
1439
|
-
if (!this.#isTextOptions(form, options)) return () => void 0;
|
|
1440
|
-
const validator = resolveValidation(options.validate);
|
|
1441
|
-
return (value) => (0, _orkestrel_contract.isString)(value) && validator(value) === true ? value : void 0;
|
|
1247
|
+
const outcome = (0, _orkestrel_contract.attempt)(() => this.#submit(parked.form, values));
|
|
1248
|
+
if (!outcome.success) return {
|
|
1249
|
+
success: false,
|
|
1250
|
+
error: {
|
|
1251
|
+
reason: "rejected",
|
|
1252
|
+
errors: this.#errors(outcome.error, parked.form)
|
|
1442
1253
|
}
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
return (value) => (0, _orkestrel_contract.isString)(value) && values.has(value) ? value : void 0;
|
|
1254
|
+
};
|
|
1255
|
+
if (!outcome.value.success) return {
|
|
1256
|
+
success: false,
|
|
1257
|
+
error: {
|
|
1258
|
+
reason: "rejected",
|
|
1259
|
+
errors: outcome.value.error
|
|
1450
1260
|
}
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
if (min !== void 0 && value.length < min) return void 0;
|
|
1459
|
-
if (max !== void 0 && value.length > max) return void 0;
|
|
1460
|
-
return value;
|
|
1461
|
-
};
|
|
1261
|
+
};
|
|
1262
|
+
parked.cancel();
|
|
1263
|
+
const answered = {
|
|
1264
|
+
...parked,
|
|
1265
|
+
pending: {
|
|
1266
|
+
...parked.pending,
|
|
1267
|
+
status: "answered"
|
|
1462
1268
|
}
|
|
1269
|
+
};
|
|
1270
|
+
this.#parked.set(id, answered);
|
|
1271
|
+
this.#emitter.emit("answer", id, outcome.value.value);
|
|
1272
|
+
this.#parked.delete(id);
|
|
1273
|
+
return outcome.value;
|
|
1274
|
+
}
|
|
1275
|
+
#submit(form, values) {
|
|
1276
|
+
for (const [field, value] of Object.entries(values)) {
|
|
1277
|
+
const outcome = (0, _orkestrel_contract.attempt)(() => form.fill(field, value));
|
|
1278
|
+
if (!outcome.success) return {
|
|
1279
|
+
success: false,
|
|
1280
|
+
error: this.#errors(outcome.error, form, field)
|
|
1281
|
+
};
|
|
1463
1282
|
}
|
|
1283
|
+
return form.submit();
|
|
1284
|
+
}
|
|
1285
|
+
#errors(error, form, field) {
|
|
1286
|
+
if ((0, _orkestrel_form.isFormError)(error)) {
|
|
1287
|
+
if ((0, _orkestrel_form.isFieldError)(error.context)) return [error.context];
|
|
1288
|
+
const named = error.context?.field;
|
|
1289
|
+
return [{
|
|
1290
|
+
field: typeof named === "string" ? named : this.#field(form, field),
|
|
1291
|
+
message: error.message
|
|
1292
|
+
}];
|
|
1293
|
+
}
|
|
1294
|
+
return [{
|
|
1295
|
+
field: this.#field(form, field),
|
|
1296
|
+
message: "The form rejected the answer"
|
|
1297
|
+
}];
|
|
1464
1298
|
}
|
|
1465
|
-
#
|
|
1466
|
-
return
|
|
1467
|
-
}
|
|
1468
|
-
#isConfirmOptions(form, _options) {
|
|
1469
|
-
return form === "confirm";
|
|
1470
|
-
}
|
|
1471
|
-
#isSelectOptions(form, _options) {
|
|
1472
|
-
return form === "select";
|
|
1473
|
-
}
|
|
1474
|
-
#isCheckboxOptions(form, _options) {
|
|
1475
|
-
return form === "checkbox";
|
|
1299
|
+
#field(form, field) {
|
|
1300
|
+
return field ?? form.schema.fields[0]?.name ?? form.schema.name ?? "form";
|
|
1476
1301
|
}
|
|
1477
1302
|
#expire(id) {
|
|
1478
1303
|
const parked = this.#parked.get(id);
|
|
1479
|
-
if (parked === void 0) return;
|
|
1304
|
+
if (parked === void 0 || parked.pending.status !== "pending") return;
|
|
1480
1305
|
parked.cancel();
|
|
1481
|
-
|
|
1306
|
+
const expired = {
|
|
1482
1307
|
...parked,
|
|
1483
|
-
|
|
1484
|
-
...parked.
|
|
1308
|
+
pending: {
|
|
1309
|
+
...parked.pending,
|
|
1485
1310
|
status: "expired"
|
|
1486
1311
|
}
|
|
1487
|
-
}
|
|
1312
|
+
};
|
|
1313
|
+
this.#parked.set(id, expired);
|
|
1314
|
+
parked.form.destroy();
|
|
1488
1315
|
this.#emitter.emit("expire", id);
|
|
1489
1316
|
this.#parked.delete(id);
|
|
1490
|
-
parked.expire();
|
|
1491
1317
|
}
|
|
1492
1318
|
};
|
|
1493
1319
|
//#endregion
|
|
1494
1320
|
//#region src/core/PromptClient.ts
|
|
1495
1321
|
/**
|
|
1496
|
-
* The SSE
|
|
1497
|
-
*
|
|
1498
|
-
* serialized pending prompts, dispatches EACH to a LOCAL {@link PromptFormInterface} terminal (so
|
|
1499
|
-
* a human at THIS machine answers a prompt parked elsewhere), and POSTs the answer back.
|
|
1500
|
-
* Universal — `fetch` + SSE are web-standard, so it runs in a browser or on a server; the
|
|
1501
|
-
* injected `fetch` / timer make it fully deterministic in tests.
|
|
1322
|
+
* The SSE form bridge. It ingests serialized forms from a remote broker, renders them through a
|
|
1323
|
+
* local terminal, and posts answers back without blocking the event stream.
|
|
1502
1324
|
*
|
|
1503
1325
|
* @remarks
|
|
1504
|
-
* - **Connect + reconnect.** {@link connect} opens the SSE stream
|
|
1505
|
-
*
|
|
1506
|
-
*
|
|
1507
|
-
*
|
|
1508
|
-
*
|
|
1509
|
-
*
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
* `shutdown` event calls {@link disconnect} (not {@link destroy}) — the client stops streaming
|
|
1518
|
-
* without auto-reconnect but stays reusable; a later {@link connect} recovers it.
|
|
1519
|
-
* - **Lean events (§13).** `connect` / `disconnect` / `expire` / `error` — errors are `unknown`.
|
|
1520
|
-
* `disconnect` fires exactly once per connected-to-disconnected transition, whether triggered by
|
|
1521
|
-
* the server ending the stream cleanly or by a deliberate {@link disconnect} / {@link destroy}.
|
|
1326
|
+
* - **Connect + reconnect.** {@link connect} opens the SSE stream and reconnects after a transport
|
|
1327
|
+
* drop with the injected backoff unless reconnect is disabled, the client was destroyed, or
|
|
1328
|
+
* {@link disconnect} deliberately stopped it.
|
|
1329
|
+
* - **Ingest + render.** Each `pending` envelope passes through `isPendingForm`, Form's `parseForm`,
|
|
1330
|
+
* and terminal's `sanitizeSchema`, then enters a serial render queue. The SSE reader never awaits
|
|
1331
|
+
* that queue, so `expire` and `shutdown` remain live while a person is answering.
|
|
1332
|
+
* - **Safe local form.** The rendering copy omits every wire `pattern`, because Form compiles a
|
|
1333
|
+
* pattern during local evaluation. The broker's parked form retains it and remains authoritative.
|
|
1334
|
+
* - **Refusal retry.** A structured `rejected` response seeds a new rendering form with the values
|
|
1335
|
+
* just submitted, applies every {@link FieldError} through `invalidate`, and asks again. No retry
|
|
1336
|
+
* counter truncates the loop; acceptance, expiry, and shutdown are its bounds.
|
|
1337
|
+
* - **Replay safety.** A replayed id is skipped while it is queued, rendering, or posting. Once an
|
|
1338
|
+
* attempt ends, a later delivery of that id may be rendered again.
|
|
1522
1339
|
*
|
|
1523
1340
|
* @example
|
|
1524
1341
|
* ```ts
|
|
1525
1342
|
* const client = createPromptClient({
|
|
1526
1343
|
* url: 'http://localhost:3001/prompts',
|
|
1527
|
-
* terminal: createTerminal(),
|
|
1528
|
-
* on: { connect: () => log('connected') },
|
|
1344
|
+
* terminal: createTerminal(),
|
|
1529
1345
|
* })
|
|
1530
|
-
* await client.connect()
|
|
1346
|
+
* await client.connect()
|
|
1531
1347
|
* ```
|
|
1532
1348
|
*/
|
|
1533
1349
|
var PromptClient = class {
|
|
@@ -1545,8 +1361,11 @@ var PromptClient = class {
|
|
|
1545
1361
|
#connecting = false;
|
|
1546
1362
|
#connected = false;
|
|
1547
1363
|
#destroyed = false;
|
|
1364
|
+
#draining = false;
|
|
1548
1365
|
#warnedInsecureToken = false;
|
|
1549
1366
|
#seen = /* @__PURE__ */ new Set();
|
|
1367
|
+
#queue = /* @__PURE__ */ new Map();
|
|
1368
|
+
#active;
|
|
1550
1369
|
constructor(options) {
|
|
1551
1370
|
this.url = options.url;
|
|
1552
1371
|
this.#terminal = options.terminal;
|
|
@@ -1567,8 +1386,7 @@ var PromptClient = class {
|
|
|
1567
1386
|
return this.#connected;
|
|
1568
1387
|
}
|
|
1569
1388
|
async connect() {
|
|
1570
|
-
if (this.#destroyed) return;
|
|
1571
|
-
if (this.#connecting) return;
|
|
1389
|
+
if (this.#destroyed || this.#connecting) return;
|
|
1572
1390
|
this.#connecting = true;
|
|
1573
1391
|
while (this.#connecting && !this.#destroyed) {
|
|
1574
1392
|
try {
|
|
@@ -1597,7 +1415,7 @@ var PromptClient = class {
|
|
|
1597
1415
|
if (this.#destroyed) return;
|
|
1598
1416
|
this.#destroyed = true;
|
|
1599
1417
|
this.disconnect();
|
|
1600
|
-
this.#
|
|
1418
|
+
this.#interrupt();
|
|
1601
1419
|
this.#emitter.destroy();
|
|
1602
1420
|
}
|
|
1603
1421
|
async #stream() {
|
|
@@ -1623,49 +1441,158 @@ var PromptClient = class {
|
|
|
1623
1441
|
for (;;) {
|
|
1624
1442
|
const { done, value } = await reader.read();
|
|
1625
1443
|
if (done) break;
|
|
1626
|
-
for (const event of parser.parse(decoder.decode(value, { stream: true })))
|
|
1444
|
+
for (const event of parser.parse(decoder.decode(value, { stream: true }))) {
|
|
1445
|
+
this.#handle(event);
|
|
1446
|
+
if (!this.#connecting) break;
|
|
1447
|
+
}
|
|
1448
|
+
if (!this.#connecting) break;
|
|
1627
1449
|
}
|
|
1628
1450
|
} finally {
|
|
1629
1451
|
reader.releaseLock();
|
|
1630
1452
|
}
|
|
1631
1453
|
this.#markDisconnected();
|
|
1632
1454
|
}
|
|
1633
|
-
|
|
1455
|
+
#handle(event) {
|
|
1634
1456
|
if (event.event === SSE_EVENTS.pending) {
|
|
1635
|
-
const parsed =
|
|
1636
|
-
if (
|
|
1457
|
+
const parsed = (0, _orkestrel_contract.parseJSON)(event.data);
|
|
1458
|
+
if (!isPendingForm(parsed) || this.#seen.has(parsed.id)) return;
|
|
1459
|
+
const schema = (0, _orkestrel_form.parseForm)(parsed.schema);
|
|
1460
|
+
if (schema === void 0) {
|
|
1461
|
+
this.#emitter.emit("error", /* @__PURE__ */ new Error(`broker sent an invalid form schema for ${parsed.id}`));
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
this.#seen.add(parsed.id);
|
|
1465
|
+
this.#queue.set(parsed.id, sanitizeSchema(schema));
|
|
1466
|
+
this.#drain();
|
|
1637
1467
|
return;
|
|
1638
1468
|
}
|
|
1639
1469
|
if (event.event === SSE_EVENTS.expire) {
|
|
1640
|
-
const parsed =
|
|
1641
|
-
if ((0, _orkestrel_contract.isRecord)(parsed) && (0, _orkestrel_contract.isString)(parsed.id))
|
|
1642
|
-
this.#seen.delete(parsed.id);
|
|
1643
|
-
this.#emitter.emit("expire", parsed.id);
|
|
1644
|
-
}
|
|
1470
|
+
const parsed = (0, _orkestrel_contract.parseJSON)(event.data);
|
|
1471
|
+
if ((0, _orkestrel_contract.isRecord)(parsed) && (0, _orkestrel_contract.isString)(parsed.id)) this.#expire(parsed.id);
|
|
1645
1472
|
return;
|
|
1646
1473
|
}
|
|
1647
|
-
if (event.event === SSE_EVENTS.shutdown)
|
|
1474
|
+
if (event.event === SSE_EVENTS.shutdown) {
|
|
1475
|
+
this.disconnect();
|
|
1476
|
+
this.#interrupt();
|
|
1477
|
+
}
|
|
1648
1478
|
}
|
|
1649
|
-
async #
|
|
1650
|
-
this.#
|
|
1479
|
+
async #drain() {
|
|
1480
|
+
if (this.#draining) return;
|
|
1481
|
+
this.#draining = true;
|
|
1651
1482
|
try {
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1483
|
+
while (!this.#destroyed) {
|
|
1484
|
+
let queued;
|
|
1485
|
+
for (const entry of this.#queue) {
|
|
1486
|
+
queued = entry;
|
|
1487
|
+
break;
|
|
1488
|
+
}
|
|
1489
|
+
if (queued === void 0) return;
|
|
1490
|
+
const [id, schema] = queued;
|
|
1491
|
+
this.#queue.delete(id);
|
|
1492
|
+
try {
|
|
1493
|
+
await this.#render(id, schema);
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
const active = this.#active;
|
|
1496
|
+
if (active?.id !== id || !active.stopped) this.#emitter.emit("error", error);
|
|
1497
|
+
} finally {
|
|
1498
|
+
const active = this.#active;
|
|
1499
|
+
if (active?.id === id) {
|
|
1500
|
+
active.form.destroy();
|
|
1501
|
+
this.#active = void 0;
|
|
1502
|
+
}
|
|
1503
|
+
if (!this.#queue.has(id)) this.#seen.delete(id);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1656
1506
|
} finally {
|
|
1657
|
-
this.#
|
|
1507
|
+
this.#draining = false;
|
|
1658
1508
|
}
|
|
1659
1509
|
}
|
|
1660
|
-
async #
|
|
1661
|
-
|
|
1510
|
+
async #render(id, schema) {
|
|
1511
|
+
let values;
|
|
1512
|
+
let errors = [];
|
|
1513
|
+
while (this.#seen.has(id) && !this.#destroyed) {
|
|
1514
|
+
const form = this.#createRenderingForm(schema, values);
|
|
1515
|
+
this.#active = {
|
|
1516
|
+
id,
|
|
1517
|
+
form,
|
|
1518
|
+
stopped: false
|
|
1519
|
+
};
|
|
1520
|
+
for (const error of errors) form.invalidate(error.field, sanitizeDisplayText(error.message));
|
|
1521
|
+
const submitted = await this.#terminal.ask(form);
|
|
1522
|
+
const active = this.#active;
|
|
1523
|
+
if (active?.id !== id || active.stopped) return;
|
|
1524
|
+
const rejected = await this.#post(id, submitted);
|
|
1525
|
+
const posted = this.#active;
|
|
1526
|
+
if (posted?.id !== id || posted.stopped || rejected === void 0) return;
|
|
1527
|
+
values = submitted;
|
|
1528
|
+
errors = rejected;
|
|
1529
|
+
form.destroy();
|
|
1530
|
+
this.#active = void 0;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
#createRenderingForm(schema, values) {
|
|
1534
|
+
const fields = schema.fields.map((field) => {
|
|
1535
|
+
if (field.rule?.pattern === void 0) return field;
|
|
1536
|
+
const { pattern: _pattern, ...rule } = field.rule;
|
|
1537
|
+
return {
|
|
1538
|
+
...field,
|
|
1539
|
+
rule
|
|
1540
|
+
};
|
|
1541
|
+
});
|
|
1542
|
+
const form = (0, _orkestrel_form.createForm)({
|
|
1543
|
+
...schema,
|
|
1544
|
+
fields
|
|
1545
|
+
}, values === void 0 ? void 0 : { values });
|
|
1546
|
+
form.answer.catch(() => void 0);
|
|
1547
|
+
return form;
|
|
1548
|
+
}
|
|
1549
|
+
async #post(id, values) {
|
|
1550
|
+
const response = await this.#fetch(this.url, {
|
|
1662
1551
|
method: "POST",
|
|
1663
1552
|
headers: this.#headers({ "Content-Type": "application/json" }),
|
|
1664
1553
|
body: JSON.stringify({
|
|
1665
1554
|
id,
|
|
1666
|
-
|
|
1555
|
+
values
|
|
1667
1556
|
})
|
|
1668
|
-
})
|
|
1557
|
+
});
|
|
1558
|
+
if (!response.ok) this.#emitter.emit("error", /* @__PURE__ */ new Error(`broker rejected answer ${id}`));
|
|
1559
|
+
const parsed = (0, _orkestrel_contract.parseJSON)(await response.text());
|
|
1560
|
+
if (!(0, _orkestrel_contract.isRecord)(parsed) || !(0, _orkestrel_contract.isBoolean)(parsed.success)) {
|
|
1561
|
+
this.#emitter.emit("error", /* @__PURE__ */ new Error(`broker returned an invalid answer result for ${id}`));
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
if (parsed.success) return void 0;
|
|
1565
|
+
const error = parsed.error;
|
|
1566
|
+
if (!(0, _orkestrel_contract.isRecord)(error) || !(0, _orkestrel_contract.isString)(error.reason)) {
|
|
1567
|
+
this.#emitter.emit("error", /* @__PURE__ */ new Error(`broker returned an invalid answer error for ${id}`));
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
if (error.reason === "unknown") return void 0;
|
|
1571
|
+
if (error.reason === "rejected" && (0, _orkestrel_contract.arrayOf)(_orkestrel_form.isFieldError)(error.errors)) return error.errors;
|
|
1572
|
+
this.#emitter.emit("error", /* @__PURE__ */ new Error(`broker returned an invalid answer refusal for ${id}`));
|
|
1573
|
+
}
|
|
1574
|
+
#expire(id) {
|
|
1575
|
+
const active = this.#active;
|
|
1576
|
+
if (active?.id === id) {
|
|
1577
|
+
this.#active = {
|
|
1578
|
+
...active,
|
|
1579
|
+
stopped: true
|
|
1580
|
+
};
|
|
1581
|
+
this.#seen.delete(id);
|
|
1582
|
+
active.form.destroy();
|
|
1583
|
+
} else if (this.#queue.delete(id)) this.#seen.delete(id);
|
|
1584
|
+
this.#emitter.emit("expire", id);
|
|
1585
|
+
}
|
|
1586
|
+
#interrupt() {
|
|
1587
|
+
this.#queue.clear();
|
|
1588
|
+
this.#seen.clear();
|
|
1589
|
+
const active = this.#active;
|
|
1590
|
+
if (active === void 0) return;
|
|
1591
|
+
this.#active = {
|
|
1592
|
+
...active,
|
|
1593
|
+
stopped: true
|
|
1594
|
+
};
|
|
1595
|
+
active.form.destroy();
|
|
1669
1596
|
}
|
|
1670
1597
|
#markDisconnected() {
|
|
1671
1598
|
if (!this.#connected) return;
|
|
@@ -1698,8 +1625,8 @@ var PromptClient = class {
|
|
|
1698
1625
|
//#region src/core/TerminalManager.ts
|
|
1699
1626
|
/**
|
|
1700
1627
|
* The multi-endpoint terminal MANAGER — a registry of named {@link PromptInterface} brokers (one
|
|
1701
|
-
* per endpoint), so several parties can `ask`
|
|
1702
|
-
* attribution edge on every parked
|
|
1628
|
+
* per endpoint), so several parties can `ask` forms of each other by NAME with a `from` → `to`
|
|
1629
|
+
* attribution edge on every parked form, and a transitive DEADLOCK check across all in-flight
|
|
1703
1630
|
* asks.
|
|
1704
1631
|
*
|
|
1705
1632
|
* @remarks
|
|
@@ -1710,22 +1637,23 @@ var PromptClient = class {
|
|
|
1710
1637
|
* - **`ask`.** The target must already be mounted via {@link add} — `ask` never auto-adds it;
|
|
1711
1638
|
* rejects `TARGET` for an unknown `to` (listing the known names). Rejects `DEADLOCK` when parking
|
|
1712
1639
|
* `from → to` would close a cycle over the CURRENT in-flight edge set (walked transitively);
|
|
1713
|
-
* otherwise parks through the target's broker and
|
|
1714
|
-
*
|
|
1640
|
+
* otherwise parks the caller's live form through the target's broker and returns that form's own
|
|
1641
|
+
* `answer` promise. Edge cleanup never alters the value or rejection the caller observes.
|
|
1715
1642
|
* - **Durable open / save.** `open(name)` restores an EMPTY broker from the `store` (parked
|
|
1716
1643
|
* Promises are process-bound and never resurrected); `save(name)` persists the endpoint's
|
|
1717
1644
|
* configured `timeout`.
|
|
1718
|
-
* - **Removal.** `remove` drops one endpoint
|
|
1719
|
-
*
|
|
1720
|
-
* idempotent.
|
|
1645
|
+
* - **Removal.** `remove` drops one endpoint, a batch (§9.2, array overload FIRST), or every
|
|
1646
|
+
* endpoint when called without an argument. It destroys each broker, which expires every form
|
|
1647
|
+
* still parked on it. `destroy` is idempotent.
|
|
1721
1648
|
*
|
|
1722
1649
|
* @example
|
|
1723
1650
|
* ```ts
|
|
1651
|
+
* const form = createForm({ fields: [{ control: 'text', name: 'name' }] })
|
|
1724
1652
|
* const manager = new TerminalManager()
|
|
1725
1653
|
* manager.add('agent')
|
|
1726
|
-
* const
|
|
1727
|
-
* manager.answer('agent', manager.pending('agent')[0].id, 'Ada')
|
|
1728
|
-
* await
|
|
1654
|
+
* const answer = manager.ask('user', 'agent', form)
|
|
1655
|
+
* manager.answer('agent', manager.pending('agent')[0].id, { name: 'Ada' })
|
|
1656
|
+
* await answer // { name: 'Ada' }
|
|
1729
1657
|
* ```
|
|
1730
1658
|
*/
|
|
1731
1659
|
var TerminalManager = class {
|
|
@@ -1769,6 +1697,8 @@ var TerminalManager = class {
|
|
|
1769
1697
|
const timer = options?.timer ?? this.#timer;
|
|
1770
1698
|
const cap = options?.cap ?? this.#cap;
|
|
1771
1699
|
const broker = createPrompt({
|
|
1700
|
+
...options?.on !== void 0 ? { on: options.on } : {},
|
|
1701
|
+
...options?.error !== void 0 ? { error: options.error } : {},
|
|
1772
1702
|
...timeout !== void 0 ? { timeout } : {},
|
|
1773
1703
|
...timer !== void 0 ? { timer } : {},
|
|
1774
1704
|
...cap !== void 0 ? { cap } : {}
|
|
@@ -1782,11 +1712,11 @@ var TerminalManager = class {
|
|
|
1782
1712
|
broker.emitter.on("answer", listeners.answer);
|
|
1783
1713
|
broker.emitter.on("expire", listeners.expire);
|
|
1784
1714
|
this.#terminals.set(name, broker);
|
|
1785
|
-
this.#config.set(name, options
|
|
1715
|
+
this.#config.set(name, { ...options });
|
|
1786
1716
|
this.#listeners.set(name, listeners);
|
|
1787
1717
|
return broker;
|
|
1788
1718
|
}
|
|
1789
|
-
ask(from, to, form
|
|
1719
|
+
ask(from, to, form) {
|
|
1790
1720
|
const broker = this.#terminals.get(to);
|
|
1791
1721
|
if (broker === void 0) {
|
|
1792
1722
|
const known = this.terminals();
|
|
@@ -1801,21 +1731,18 @@ var TerminalManager = class {
|
|
|
1801
1731
|
to,
|
|
1802
1732
|
path: cycle
|
|
1803
1733
|
}));
|
|
1804
|
-
const
|
|
1805
|
-
form,
|
|
1806
|
-
options,
|
|
1734
|
+
const id = broker.park(form, {
|
|
1807
1735
|
from,
|
|
1808
1736
|
to
|
|
1809
1737
|
});
|
|
1810
|
-
if (broker.pending(
|
|
1811
|
-
this.#edges.set(
|
|
1738
|
+
if (broker.pending(id) !== void 0) {
|
|
1739
|
+
this.#edges.set(id, {
|
|
1812
1740
|
from,
|
|
1813
1741
|
to
|
|
1814
1742
|
});
|
|
1815
|
-
|
|
1816
|
-
ticket.value.then(clear, clear);
|
|
1743
|
+
form.answer.then(this.#createEdgeClear(id), this.#createEdgeClear(id));
|
|
1817
1744
|
}
|
|
1818
|
-
return
|
|
1745
|
+
return form.answer;
|
|
1819
1746
|
}
|
|
1820
1747
|
pending(to) {
|
|
1821
1748
|
if (to !== void 0) {
|
|
@@ -1826,13 +1753,15 @@ var TerminalManager = class {
|
|
|
1826
1753
|
for (const broker of this.#terminals.values()) result.push(...broker.pending());
|
|
1827
1754
|
return result;
|
|
1828
1755
|
}
|
|
1829
|
-
answer(to, id,
|
|
1756
|
+
answer(to, id, values) {
|
|
1830
1757
|
const broker = this.#terminals.get(to);
|
|
1831
1758
|
if (broker === void 0) return {
|
|
1832
1759
|
success: false,
|
|
1833
|
-
error: "terminal"
|
|
1760
|
+
error: { reason: "terminal" }
|
|
1834
1761
|
};
|
|
1835
|
-
|
|
1762
|
+
const result = broker.answer(id, values);
|
|
1763
|
+
if (result.success) this.#edges.delete(id);
|
|
1764
|
+
return result;
|
|
1836
1765
|
}
|
|
1837
1766
|
async open(name) {
|
|
1838
1767
|
if (this.#destroyed) throw new TerminalError("DESTROYED", "manager destroyed");
|
|
@@ -1856,6 +1785,10 @@ var TerminalManager = class {
|
|
|
1856
1785
|
return true;
|
|
1857
1786
|
}
|
|
1858
1787
|
remove(names) {
|
|
1788
|
+
if (names === void 0) {
|
|
1789
|
+
for (const name of [...this.#terminals.keys()]) this.#removeOne(name);
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1859
1792
|
if ((0, _orkestrel_contract.isArray)(names)) {
|
|
1860
1793
|
let removed = false;
|
|
1861
1794
|
for (const name of names) if (this.#removeOne(name)) removed = true;
|
|
@@ -1863,23 +1796,27 @@ var TerminalManager = class {
|
|
|
1863
1796
|
}
|
|
1864
1797
|
return this.#removeOne(names);
|
|
1865
1798
|
}
|
|
1866
|
-
clear() {
|
|
1867
|
-
for (const name of [...this.#terminals.keys()]) this.#removeOne(name);
|
|
1868
|
-
}
|
|
1869
1799
|
destroy() {
|
|
1870
1800
|
if (this.#destroyed) return;
|
|
1871
1801
|
this.#destroyed = true;
|
|
1872
|
-
this.
|
|
1802
|
+
this.remove();
|
|
1803
|
+
this.#edges.clear();
|
|
1873
1804
|
this.#emitter.destroy();
|
|
1874
1805
|
}
|
|
1875
1806
|
#createPendingListener() {
|
|
1876
|
-
return (
|
|
1807
|
+
return (form) => this.#emitter.emit("pending", form);
|
|
1877
1808
|
}
|
|
1878
1809
|
#createAnswerListener(name) {
|
|
1879
|
-
return (id,
|
|
1810
|
+
return (id, values) => {
|
|
1811
|
+
this.#edges.delete(id);
|
|
1812
|
+
this.#emitter.emit("answer", name, id, values);
|
|
1813
|
+
};
|
|
1880
1814
|
}
|
|
1881
1815
|
#createExpireListener(name) {
|
|
1882
|
-
return (id) =>
|
|
1816
|
+
return (id) => {
|
|
1817
|
+
this.#edges.delete(id);
|
|
1818
|
+
this.#emitter.emit("expire", name, id);
|
|
1819
|
+
};
|
|
1883
1820
|
}
|
|
1884
1821
|
#createEdgeClear(id) {
|
|
1885
1822
|
return () => {
|
|
@@ -1899,8 +1836,12 @@ var TerminalManager = class {
|
|
|
1899
1836
|
this.#terminals.delete(name);
|
|
1900
1837
|
this.#config.delete(name);
|
|
1901
1838
|
this.#listeners.delete(name);
|
|
1839
|
+
this.#clearEdges(name);
|
|
1902
1840
|
return true;
|
|
1903
1841
|
}
|
|
1842
|
+
#clearEdges(to) {
|
|
1843
|
+
for (const [id, edge] of this.#edges) if (edge.to === to) this.#edges.delete(id);
|
|
1844
|
+
}
|
|
1904
1845
|
#findCycle(from, to) {
|
|
1905
1846
|
if (from === to) return [from, to];
|
|
1906
1847
|
const visited = /* @__PURE__ */ new Set([to]);
|
|
@@ -2048,30 +1989,26 @@ var DatabaseTerminalStore = class {
|
|
|
2048
1989
|
//#endregion
|
|
2049
1990
|
//#region src/core/factories.ts
|
|
2050
1991
|
/**
|
|
2051
|
-
* Create the headless
|
|
2052
|
-
*
|
|
2053
|
-
* timeout). The tri-surface's headless arm: subscribe `emitter.on('pending', …)` to forward each
|
|
2054
|
-
* prompt to whoever can answer (an SSE transport, a remote terminal), then route the answer back
|
|
2055
|
-
* through `answer(id, value)`.
|
|
1992
|
+
* Create the headless {@link PromptInterface} broker. It parks live forms and applies remote
|
|
1993
|
+
* answers to the authoritative instances.
|
|
2056
1994
|
*
|
|
2057
1995
|
* @param options - See {@link PromptOptions}
|
|
2058
1996
|
* @returns A {@link PromptInterface}
|
|
2059
1997
|
*
|
|
2060
1998
|
* @remarks
|
|
2061
|
-
*
|
|
2062
|
-
*
|
|
2063
|
-
*
|
|
2064
|
-
* `options.timeout` (default {@link import('./constants.js').DEFAULT_PROMPT_TIMEOUT_MS}) and its
|
|
2065
|
-
* Promise rejects with a {@link import('./errors.js').TerminalError}; inject `options.timer` to
|
|
2066
|
-
* drive expiry without real time.
|
|
1999
|
+
* The caller awaits the parked form's own `answer`. Timeout or teardown destroys the form, so that
|
|
2000
|
+
* promise rejects with the Form package's `ABANDONED` error. Inject `options.timer` to drive
|
|
2001
|
+
* expiry without real time.
|
|
2067
2002
|
*
|
|
2068
2003
|
* @example
|
|
2069
2004
|
* ```ts
|
|
2070
2005
|
* import { createPrompt } from '@src/core'
|
|
2006
|
+
* import { createForm } from '@orkestrel/form'
|
|
2071
2007
|
*
|
|
2072
2008
|
* const prompt = createPrompt()
|
|
2073
|
-
*
|
|
2074
|
-
* const
|
|
2009
|
+
* const form = createForm({ fields: [{ control: 'text', name: 'name' }] })
|
|
2010
|
+
* const id = prompt.park(form)
|
|
2011
|
+
* prompt.answer(id, { name: 'Ada' })
|
|
2075
2012
|
* ```
|
|
2076
2013
|
*/
|
|
2077
2014
|
function createPrompt(options) {
|
|
@@ -2079,9 +2016,8 @@ function createPrompt(options) {
|
|
|
2079
2016
|
}
|
|
2080
2017
|
/**
|
|
2081
2018
|
* Create the SSE prompt {@link PromptClientInterface} BRIDGE — it connects to a remote broker's SSE
|
|
2082
|
-
* endpoint, dispatches each received
|
|
2083
|
-
*
|
|
2084
|
-
* back. Universal — `fetch` / SSE are web-standard.
|
|
2019
|
+
* endpoint, dispatches each received form to a local {@link import('./types.js').TerminalInterface},
|
|
2020
|
+
* and POSTs the answer back. Universal — `fetch` / SSE are web-standard.
|
|
2085
2021
|
*
|
|
2086
2022
|
* @param options - See {@link PromptClientOptions} (`url` + `terminal` required)
|
|
2087
2023
|
* @returns A {@link PromptClientInterface}
|
|
@@ -2105,12 +2041,12 @@ function createPromptClient(options) {
|
|
|
2105
2041
|
return new PromptClient(options);
|
|
2106
2042
|
}
|
|
2107
2043
|
/**
|
|
2108
|
-
* Create the multi-endpoint {@link
|
|
2044
|
+
* Create the multi-endpoint {@link TerminalManager} — a named registry of
|
|
2109
2045
|
* {@link PromptInterface} brokers so several parties can `ask` prompts of each other by name,
|
|
2110
2046
|
* with a transitive DEADLOCK check across every in-flight ask.
|
|
2111
2047
|
*
|
|
2112
2048
|
* @param options - See {@link TerminalManagerOptions}
|
|
2113
|
-
* @returns A {@link
|
|
2049
|
+
* @returns A {@link TerminalManager}
|
|
2114
2050
|
*
|
|
2115
2051
|
* @example
|
|
2116
2052
|
* ```ts
|
|
@@ -2165,7 +2101,6 @@ function createDatabaseTerminalStore(driver = (0, _orkestrel_database.createMemo
|
|
|
2165
2101
|
}
|
|
2166
2102
|
//#endregion
|
|
2167
2103
|
exports.ACCEPT_EVENT_STREAM = ACCEPT_EVENT_STREAM;
|
|
2168
|
-
exports.ALPHANUMERIC_PATTERN = ALPHANUMERIC_PATTERN;
|
|
2169
2104
|
exports.BACKSPACE = BACKSPACE;
|
|
2170
2105
|
exports.CONTROL_NAMES = CONTROL_NAMES;
|
|
2171
2106
|
exports.CTRL_A = CTRL_A;
|
|
@@ -2174,24 +2109,22 @@ exports.CTRL_D = CTRL_D;
|
|
|
2174
2109
|
exports.CTRL_E = CTRL_E;
|
|
2175
2110
|
exports.CTRL_U = CTRL_U;
|
|
2176
2111
|
exports.DEFAULT_MASK = DEFAULT_MASK;
|
|
2112
|
+
exports.DEFAULT_PROMPT_THEME = DEFAULT_PROMPT_THEME;
|
|
2177
2113
|
exports.DEFAULT_PROMPT_TIMEOUT_MS = DEFAULT_PROMPT_TIMEOUT_MS;
|
|
2178
2114
|
exports.DEFAULT_RECONNECT_DELAY_MS = DEFAULT_RECONNECT_DELAY_MS;
|
|
2179
2115
|
exports.DELETE = DELETE;
|
|
2180
2116
|
exports.DatabaseTerminalStore = DatabaseTerminalStore;
|
|
2181
|
-
exports.EMAIL_PATTERN = EMAIL_PATTERN;
|
|
2182
2117
|
exports.ESCAPE = ESCAPE;
|
|
2183
2118
|
exports.HEADER_TOKEN = HEADER_TOKEN;
|
|
2184
|
-
exports.INTEGER_PATTERN = INTEGER_PATTERN;
|
|
2185
2119
|
exports.KEY_CSI = KEY_CSI;
|
|
2186
2120
|
exports.KEY_SS3 = KEY_SS3;
|
|
2187
2121
|
exports.MemoryTerminalStore = MemoryTerminalStore;
|
|
2188
2122
|
exports.NEWLINE = NEWLINE;
|
|
2189
|
-
exports.NUMERIC_PATTERN = NUMERIC_PATTERN;
|
|
2190
2123
|
exports.PROMPT_ICONS = PROMPT_ICONS;
|
|
2124
|
+
exports.PROMPT_ROLES = PROMPT_ROLES;
|
|
2191
2125
|
exports.Prompt = Prompt;
|
|
2192
2126
|
exports.PromptClient = PromptClient;
|
|
2193
2127
|
exports.RETURN = RETURN;
|
|
2194
|
-
exports.RULE_MESSAGES = RULE_MESSAGES;
|
|
2195
2128
|
exports.SEQUENCE_NAMES = SEQUENCE_NAMES;
|
|
2196
2129
|
exports.SPACE = SPACE;
|
|
2197
2130
|
exports.SSE_BUFFER_LIMIT = SSE_BUFFER_LIMIT;
|
|
@@ -2199,12 +2132,8 @@ exports.SSE_EVENTS = SSE_EVENTS;
|
|
|
2199
2132
|
exports.TAB = TAB;
|
|
2200
2133
|
exports.TerminalError = TerminalError;
|
|
2201
2134
|
exports.TerminalManager = TerminalManager;
|
|
2202
|
-
exports.URL_PATTERN = URL_PATTERN;
|
|
2203
|
-
exports.appendRule = appendRule;
|
|
2204
|
-
exports.buildRuleValidator = buildRuleValidator;
|
|
2205
2135
|
exports.checkboxReduce = checkboxReduce;
|
|
2206
2136
|
exports.checkboxView = checkboxView;
|
|
2207
|
-
exports.composeValidators = composeValidators;
|
|
2208
2137
|
exports.confirmReduce = confirmReduce;
|
|
2209
2138
|
exports.confirmView = confirmView;
|
|
2210
2139
|
exports.createCheckboxState = createCheckboxState;
|
|
@@ -2216,51 +2145,38 @@ exports.createMemoryTerminalStore = createMemoryTerminalStore;
|
|
|
2216
2145
|
exports.createPasswordState = createPasswordState;
|
|
2217
2146
|
exports.createPrompt = createPrompt;
|
|
2218
2147
|
exports.createPromptClient = createPromptClient;
|
|
2148
|
+
exports.createPromptTheme = createPromptTheme;
|
|
2219
2149
|
exports.createSelectState = createSelectState;
|
|
2220
2150
|
exports.createTerminalManager = createTerminalManager;
|
|
2221
2151
|
exports.defaultTimer = defaultTimer;
|
|
2222
|
-
exports.dispatchPendingPrompt = dispatchPendingPrompt;
|
|
2223
2152
|
exports.editLine = editLine;
|
|
2224
2153
|
exports.editorReduce = editorReduce;
|
|
2225
2154
|
exports.editorView = editorView;
|
|
2226
2155
|
exports.errorLine = errorLine;
|
|
2227
|
-
exports.evaluateRule = evaluateRule;
|
|
2228
|
-
exports.gateSelection = gateSelection;
|
|
2229
2156
|
exports.globalFetch = globalFetch;
|
|
2157
|
+
exports.hintedHeader = hintedHeader;
|
|
2230
2158
|
exports.inputReduce = inputReduce;
|
|
2231
2159
|
exports.inputView = inputView;
|
|
2232
2160
|
exports.isAbortError = isAbortError;
|
|
2233
|
-
exports.isAnswerPayload = isAnswerPayload;
|
|
2234
|
-
exports.isCheckboxChoice = isCheckboxChoice;
|
|
2235
2161
|
exports.isInsecureRemote = isInsecureRemote;
|
|
2236
|
-
exports.
|
|
2237
|
-
exports.
|
|
2162
|
+
exports.isPendingForm = isPendingForm;
|
|
2163
|
+
exports.isPendingFormStatus = isPendingFormStatus;
|
|
2238
2164
|
exports.isPrintable = isPrintable;
|
|
2239
|
-
exports.isPromptChoice = isPromptChoice;
|
|
2240
|
-
exports.isPromptType = isPromptType;
|
|
2241
2165
|
exports.isTerminalError = isTerminalError;
|
|
2242
2166
|
exports.isTerminalSnapshot = isTerminalSnapshot;
|
|
2243
|
-
exports.
|
|
2244
|
-
exports.normalizeChoice = normalizeChoice;
|
|
2167
|
+
exports.isWireEvent = isWireEvent;
|
|
2245
2168
|
exports.parseKey = parseKey;
|
|
2246
|
-
exports.parseWireJSON = parseWireJSON;
|
|
2247
|
-
exports.passing = passing;
|
|
2248
2169
|
exports.passwordReduce = passwordReduce;
|
|
2249
2170
|
exports.passwordView = passwordView;
|
|
2250
2171
|
exports.promptHeader = promptHeader;
|
|
2251
|
-
exports.
|
|
2252
|
-
exports.
|
|
2253
|
-
exports.
|
|
2254
|
-
exports.resolveValidation = resolveValidation;
|
|
2255
|
-
exports.sanitizeChoiceLabels = sanitizeChoiceLabels;
|
|
2172
|
+
exports.sanitizeDisplayText = sanitizeDisplayText;
|
|
2173
|
+
exports.sanitizeSchema = sanitizeSchema;
|
|
2174
|
+
exports.sanitizeThemeIcons = sanitizeThemeIcons;
|
|
2256
2175
|
exports.selectReduce = selectReduce;
|
|
2257
2176
|
exports.selectView = selectView;
|
|
2258
|
-
exports.serializeChoices = serializeChoices;
|
|
2259
2177
|
exports.serializeExpire = serializeExpire;
|
|
2260
2178
|
exports.serializePending = serializePending;
|
|
2261
|
-
exports.serializePromptOptions = serializePromptOptions;
|
|
2262
2179
|
exports.serializeShutdown = serializeShutdown;
|
|
2263
|
-
exports.serializeValidationRules = serializeValidationRules;
|
|
2264
2180
|
exports.submitHeader = submitHeader;
|
|
2265
2181
|
exports.toggleIndex = toggleIndex;
|
|
2266
2182
|
|