bare-tui-form 0.0.0 → 0.0.2

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/harden.js ADDED
@@ -0,0 +1,173 @@
1
+ // harden — the security boundary for untrusted JSON Schema.
2
+ //
3
+ // fromSchema turns externally-supplied data (an LLM- or peer-authored schema,
4
+ // plus rehydration `formData`) into compiled regexes and strings painted onto a
5
+ // terminal. That makes it the one place in this library that ingests data an
6
+ // attacker may control, so it's the one place we treat as hostile. These
7
+ // primitives are the defenses; schema.js applies them at the boundary.
8
+ //
9
+ // The posture is secure-by-default: limits are enforced, control characters are
10
+ // stripped, and risky regexes are refused. A caller who genuinely trusts the
11
+ // source opts back into full fidelity with { trusted: true } / { limits }.
12
+
13
+ // Caps that bound how much work a single schema can cause. Generous enough for
14
+ // real forms, low enough that a malicious schema can't exhaust memory/CPU.
15
+ const DEFAULT_LIMITS = {
16
+ maxFields: 200, // total leaf fields across the whole (possibly nested) schema
17
+ maxDepth: 8, // how deeply objects may nest (a recursion / stack-overflow bound)
18
+ maxEnum: 1000, // entries in an enum / items.enum (and rehydrated arrays)
19
+ maxBranches: 20, // branches in a oneOf/anyOf variant selector
20
+ maxArrayItems: 100, // entries an array-of-objects can hold (add + rehydrate)
21
+ maxStringLength: 100000, // ceiling for a text field's charLimit
22
+ maxPatternLength: 1000, // source length of a `pattern` regex
23
+ maxTextLength: 5000, // length any single rendered string is truncated to
24
+ maxInputTested: 10000 // input length a `pattern` is tested against
25
+ }
26
+
27
+ // Property names that, used as an object key, can corrupt the prototype chain
28
+ // when the collected values object is built (out[key] = …). Refused outright.
29
+ const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
30
+
31
+ // C0 controls (incl. ESC 0x1b, the start of every ANSI/OSC sequence), DEL, and
32
+ // C1 controls (incl. CSI 0x9b). Anything in here, rendered to a terminal, can
33
+ // move the cursor, rewrite the screen, set the window title, or worse — so we
34
+ // strip it from every schema-derived string before it can reach the output.
35
+ // Built from an ASCII string literal so no control bytes live in this source.
36
+ const CONTROL_CHARS = new RegExp('[\\u0000-\\u001f\\u007f-\\u009f]', 'g')
37
+
38
+ // Coerce to a string, strip terminal-control characters, and truncate. Returns
39
+ // the value untouched when it's null/undefined so "absent" stays absent.
40
+ function cleanText(value, max = DEFAULT_LIMITS.maxTextLength) {
41
+ if (value === null || value === undefined) return value
42
+ let s = String(value).replace(CONTROL_CHARS, '')
43
+ if (max > 0 && s.length > max) s = s.slice(0, max)
44
+ return s
45
+ }
46
+
47
+ // A finite number, or undefined. JSON Schema constraints (minimum, maxLength, …)
48
+ // are trusted only when they really are finite numbers; "5", NaN, Infinity, and
49
+ // objects are ignored rather than fed into comparisons or RegExp/charLimit math.
50
+ function safeNumber(n) {
51
+ return typeof n === 'number' && Number.isFinite(n) ? n : undefined
52
+ }
53
+
54
+ // Throw if a property name would be dangerous as an object key.
55
+ function assertSafeKey(name) {
56
+ if (FORBIDDEN_KEYS.has(name)) {
57
+ throw new Error(`fromSchema: property name "${name}" is not allowed`)
58
+ }
59
+ }
60
+
61
+ // Heuristic ReDoS guard. The catastrophic-backtracking family is an unbounded
62
+ // quantifier applied to a group that itself contains an unbounded quantifier:
63
+ // (a+)+, (a*)*, (.*)*, ([a-z]+)* … We scan for exactly that shape. The check is
64
+ // deliberately conservative — it may refuse some safe patterns, in which case
65
+ // the pattern constraint is dropped (the form still works, just without that
66
+ // one client-side check) and a warning is recorded. It is NOT a general
67
+ // safe-regex prover; { trusted: true } bypasses it for known-good schemas.
68
+ function isSafeRegexSource(src) {
69
+ let escaped = false
70
+ let inClass = false
71
+ let prevWasGroupClose = false
72
+ let closedGroupUnbounded = false
73
+ // Per currently-open group: did it contain an unbounded quantifier?
74
+ const groupUnbounded = []
75
+
76
+ for (let i = 0; i < src.length; i++) {
77
+ const c = src[i]
78
+
79
+ if (escaped) {
80
+ escaped = false
81
+ prevWasGroupClose = false
82
+ continue
83
+ }
84
+ if (c === '\\') {
85
+ escaped = true
86
+ prevWasGroupClose = false
87
+ continue
88
+ }
89
+ if (inClass) {
90
+ if (c === ']') inClass = false
91
+ prevWasGroupClose = false
92
+ continue
93
+ }
94
+ if (c === '[') {
95
+ inClass = true
96
+ prevWasGroupClose = false
97
+ continue
98
+ }
99
+ if (c === '(') {
100
+ groupUnbounded.push(false)
101
+ prevWasGroupClose = false
102
+ continue
103
+ }
104
+ if (c === ')') {
105
+ closedGroupUnbounded = groupUnbounded.pop() || false
106
+ prevWasGroupClose = true
107
+ continue
108
+ }
109
+
110
+ if (c === '*' || c === '+' || c === '{') {
111
+ const unbounded = c === '{' ? isUnboundedBrace(src, i) : true
112
+ // A nested unbounded quantifier: refuse.
113
+ if (unbounded && prevWasGroupClose && closedGroupUnbounded) return false
114
+ // Mark the enclosing group as containing an unbounded quantifier.
115
+ if (unbounded && groupUnbounded.length > 0) {
116
+ groupUnbounded[groupUnbounded.length - 1] = true
117
+ }
118
+ prevWasGroupClose = false
119
+ continue
120
+ }
121
+
122
+ prevWasGroupClose = false
123
+ }
124
+ return true
125
+ }
126
+
127
+ // Is the `{…}` quantifier starting at `i` open-ended (`{n,}`) — the only brace
128
+ // form that backtracks unboundedly? `{n}` and `{n,m}` are bounded.
129
+ function isUnboundedBrace(src, i) {
130
+ const close = src.indexOf('}', i)
131
+ if (close === -1) return false // a literal '{', not a quantifier
132
+ const body = src.slice(i + 1, close)
133
+ return /^\d+,\s*$/.test(body) // n, with nothing after the comma
134
+ }
135
+
136
+ // Compile a `pattern` into a bounded tester, or return why it was refused.
137
+ // Returns { test } on success or { reason } when dropped. The tester caps the
138
+ // input length it inspects so even a linear-time regex can't be driven to
139
+ // pathological cost by a huge pasted value.
140
+ function compilePattern(source, opts = {}) {
141
+ const trusted = opts.trusted === true
142
+ const maxPatternLength = opts.maxPatternLength ?? DEFAULT_LIMITS.maxPatternLength
143
+ const maxInputTested = opts.maxInputTested ?? DEFAULT_LIMITS.maxInputTested
144
+
145
+ if (typeof source !== 'string' || source === '') {
146
+ return { reason: 'pattern ignored (not a string)' }
147
+ }
148
+ if (source.length > maxPatternLength) {
149
+ return { reason: `pattern ignored (longer than ${maxPatternLength} chars)` }
150
+ }
151
+ if (!trusted && !isSafeRegexSource(source)) {
152
+ return { reason: 'pattern ignored (rejected as a possible ReDoS)' }
153
+ }
154
+ let re
155
+ try {
156
+ re = new RegExp(source)
157
+ } catch {
158
+ return { reason: 'pattern ignored (not a valid regular expression)' }
159
+ }
160
+ const test = (v) => re.test(String(v).slice(0, maxInputTested))
161
+ return { test }
162
+ }
163
+
164
+ module.exports = {
165
+ DEFAULT_LIMITS,
166
+ FORBIDDEN_KEYS,
167
+ cleanText,
168
+ safeNumber,
169
+ assertSafeKey,
170
+ isSafeRegexSource,
171
+ isUnboundedBrace,
172
+ compilePattern
173
+ }
package/index.js ADDED
@@ -0,0 +1,86 @@
1
+ // bare-tui-form — a declarative form builder for bare-tui.
2
+ //
3
+ // Built entirely on bare-tui's field controls (textinput, textarea, select,
4
+ // radio, checkbox) and its focus ring: this package adds the orchestration
5
+ // layer — labels, descriptions, per-field validation, focus movement, and
6
+ // submission — that a form needs but a control library deliberately leaves out.
7
+ //
8
+ // const form = require('bare-tui-form')
9
+ //
10
+ // const f = form.create({
11
+ // title: 'Sign up',
12
+ // fields: [
13
+ // form.text({ name: 'name', label: 'Name', required: true }),
14
+ // form.text({ name: 'email', label: 'Email', validate: isEmail }),
15
+ // form.select({ name: 'plan', label: 'Plan', options: ['free', 'pro'] }),
16
+ // form.confirm({ name: 'tos', label: 'Accept terms', required: true })
17
+ // ]
18
+ // })
19
+ //
20
+ // const values = await form.run(f) // { name, email, plan, tos } | null
21
+ //
22
+ // Fields can also be plain `{ type, name, … }` objects, which is the shape a
23
+ // JSON-Schema mapper will produce — see the README for that planned path.
24
+ const { create, run, Form, fromDef } = require('./form')
25
+ const { fromSchema } = require('./schema')
26
+ const { defaultTheme, frame, frames } = require('./theme')
27
+
28
+ const { text, TextField } = require('./fields/text')
29
+ const { textarea, TextareaField } = require('./fields/textarea')
30
+ const { number, NumberField } = require('./fields/number')
31
+ const { select, SelectField } = require('./fields/select')
32
+ const { radio, RadioField } = require('./fields/radio')
33
+ const { confirm, ConfirmField } = require('./fields/confirm')
34
+ const { multiselect, MultiSelectField } = require('./fields/multiselect')
35
+ const { file, FileField } = require('./fields/file')
36
+ const { list, ListField } = require('./fields/list')
37
+ const { section, SectionToggleField } = require('./fields/section')
38
+ const { constant, ConstField } = require('./fields/constant')
39
+ const { action, ActionField } = require('./fields/action')
40
+ const { group, array } = require('./structural')
41
+ const { Field } = require('./fields/base')
42
+
43
+ module.exports = {
44
+ // Form
45
+ create, // form.create({ title, fields }) → Form
46
+ run, // form.run(form, programOpts?) → Promise<values | null>
47
+ Form,
48
+ fromDef, // fromDef({ type, … }) → field instance (the schema-friendly hook)
49
+ fromSchema, // fromSchema(jsonSchema, opts?) → Form built from a JSON Schema
50
+ defaultTheme, // the default theme object, to spread/extend in form.create({ theme })
51
+ frame, // form.frame({ border, color, background, padding, width }) → a theme.frame fn
52
+ frames, // ready-made frame presets: frames.rounded / .normal / .thick / .double
53
+
54
+ // Field factories
55
+ text,
56
+ textarea,
57
+ number,
58
+ select,
59
+ radio,
60
+ confirm,
61
+ multiselect,
62
+ file, // a type-or-browse path field (filepicker overlay); ui:widget 'file' / 'directory'
63
+ list, // an editable list of scalar text rows (array of string items)
64
+ section, // the optional-section gate (usually produced by fromSchema, not hand-written)
65
+ constant, // a fixed value (JSON Schema const); usually produced by fromSchema
66
+ action, // a focusable button (array add/remove); usually produced by fromSchema
67
+
68
+ // Structural helpers for hand-built forms (nested objects, repeatable arrays)
69
+ group, // form.group({ name, fields, optional }) → a nested object section
70
+ array, // form.array({ name, fields, minItems, maxItems, addable, removable })
71
+
72
+ // Classes (for extension / instanceof)
73
+ Field,
74
+ TextField,
75
+ TextareaField,
76
+ NumberField,
77
+ SelectField,
78
+ RadioField,
79
+ ConfirmField,
80
+ MultiSelectField,
81
+ FileField,
82
+ ListField,
83
+ SectionToggleField,
84
+ ConstField,
85
+ ActionField
86
+ }
package/package.json CHANGED
@@ -1 +1,52 @@
1
- {"name":"bare-tui-form","version":"0.0.0"}
1
+ {
2
+ "name": "bare-tui-form",
3
+ "version": "0.0.2",
4
+ "description": "A declarative form builder for bare-tui",
5
+ "main": "index.js",
6
+ "exports": {
7
+ "./package": "./package.json",
8
+ ".": "./index.js",
9
+ "./harden": "./harden.js",
10
+ "./prompt": "./prompt.js"
11
+ },
12
+ "files": [
13
+ "package.json",
14
+ "index.js",
15
+ "form.js",
16
+ "theme.js",
17
+ "schema.js",
18
+ "harden.js",
19
+ "prompt.js",
20
+ "structural.js",
21
+ "fields",
22
+ "docs",
23
+ "README.md"
24
+ ],
25
+ "dependencies": {
26
+ "bare-tui": "^0.0.3"
27
+ },
28
+ "devDependencies": {
29
+ "bare-fs": "^4.7.1",
30
+ "bare-path": "^3.0.0",
31
+ "bare-stream": "^2.13.1",
32
+ "brittle": "^3.19.0",
33
+ "lunte": "^1.2.0",
34
+ "prettier": "^3.6.2",
35
+ "prettier-config-holepunch": "^2.0.0"
36
+ },
37
+ "scripts": {
38
+ "format": "prettier --write . && lunte --fix",
39
+ "lint": "prettier --check . && lunte",
40
+ "test": "brittle-bare --coverage test/index.js"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/holepunchto/bare-tui-form.git"
45
+ },
46
+ "author": "Holepunch",
47
+ "license": "Apache-2.0",
48
+ "bugs": {
49
+ "url": "https://github.com/holepunchto/bare-tui-form/issues"
50
+ },
51
+ "homepage": "https://github.com/holepunchto/bare-tui-form"
52
+ }
package/prompt.js ADDED
@@ -0,0 +1,107 @@
1
+ // prompt.js — embeddable instructions that teach an LLM to emit a JSON Schema
2
+ // (and, optionally, a uiSchema) that bare-tui-form can render into a terminal
3
+ // form and collect from a user.
4
+ //
5
+ // This is meant to be *imported into your app* and dropped into your model's
6
+ // system prompt — not read from this repo at runtime — so an embedded LLM can
7
+ // drive form collection dynamically:
8
+ //
9
+ // const { build } = require('bare-tui-form/prompt')
10
+ //
11
+ // const system = build() // flat fields only (smallest)
12
+ // const system = build({ nesting: true }) // + objects/arrays/oneOf/if
13
+ // const system = build({ uiSchema: true }) // + presentation, two outputs
14
+ //
15
+ // Then feed the model's JSON straight into fromSchema:
16
+ //
17
+ // const { fromSchema } = require('bare-tui-form')
18
+ // const f = fromSchema(JSON.parse(modelJson)) // (parse uiSchema too if used)
19
+ //
20
+ // It is written for *small* models, so the exported strings are terse. The
21
+ // layers are separate exports (CORE / NESTING / UI_SCHEMA) so you only spend
22
+ // tokens on what your forms actually use; build() assembles them plus the
23
+ // matching output contract. The verbose comments here are for you, the
24
+ // developer — they are not part of any exported string.
25
+ //
26
+ // SECURITY: every string the model produces is untrusted and fromSchema is
27
+ // hardened for exactly that (see harden.js / schema.js). The prompt does not
28
+ // relax those defenses; it only shapes what a cooperative model emits.
29
+
30
+ // What the form can do with a flat object schema — the 80% case.
31
+ const CORE = `You collect data from a person with a form. You describe the form as ONE JSON Schema object; it is rendered in a terminal and the answers come back as JSON matching it.
32
+
33
+ Rules:
34
+ - The schema root is {"type":"object","properties":{...}}. Each property is one field.
35
+ - Give each property a short "title" (the on-screen label). Add a one-line "description" only when it genuinely helps.
36
+ - Put mandatory property names in the root "required" array.
37
+ - "default" pre-fills a field.
38
+
39
+ Field type comes from the property's "type":
40
+ - "string" -> text box. Checks: "minLength", "maxLength", "pattern" (regex), "format":"email" or "uri".
41
+ - "integer"/"number" -> number box; "minimum"/"maximum" bound it.
42
+ - "boolean" -> a yes/no checkbox.
43
+ - "string" with "enum":[...] -> single-choice menu. Add "enumNames":[...] (same order) for nicer labels.
44
+
45
+ Stay minimal: only the properties you need, short titles, no filler.
46
+
47
+ Example -- "sign up: name, email, plan":
48
+ {"type":"object","title":"Sign up","required":["name","email"],"properties":{"name":{"type":"string","title":"Full name","minLength":1},"email":{"type":"string","title":"Email","format":"email"},"plan":{"type":"string","title":"Plan","enum":["free","pro"],"enumNames":["Free","Pro"],"default":"free"},"news":{"type":"boolean","title":"Email me updates"}}}`
49
+
50
+ // Structure: nested objects, repeatable/multi arrays, oneOf/anyOf, if/then/else.
51
+ // Append only when your forms need shape beyond a flat list.
52
+ const NESTING = `Structure (use only when the data needs it):
53
+ - Nested object: a property with "type":"object" and its own "properties" -> a sub-section. In the parent "required" it is always shown; otherwise it is optional and the user opts in with a checkbox.
54
+ - Pick many: "type":"array","items":{"enum":[...]} -> a multi-select list.
55
+ - Repeatable group: "type":"array","items":{"type":"object","properties":{...}} -> the user adds/removes entries; "minItems"/"maxItems" bound the count.
56
+ - One of several shapes: "oneOf" (or "anyOf") = a list of branches. Make them ALL scalar ({"const":x} or {"enum":[...]}) -> a value picker, OR ALL objects ({"properties":{...}}) -> the user picks a branch and fills it. Never mix the two.
57
+ - Conditional fields: "if":{"properties":{"kind":{"const":"card"}}},"then":{"properties":{...}},"else":{"properties":{...}} -> the then/else fields appear based on another field. "if" may only test "const" or "enum" on sibling properties.
58
+ - Fixed value: "const" on a property -> a non-editable value (useful as a oneOf branch tag).
59
+
60
+ Never emit (these are rejected): arrays of plain strings/numbers (use items.enum or object items), arrays inside arrays, "allOf", "$ref".`
61
+
62
+ // Presentation layer. Mirrors RJSF's uiSchema. Changes the OUTPUT CONTRACT:
63
+ // the model must now emit two objects. build({ uiSchema: true }) swaps in
64
+ // BOTH_OUTPUT to say so.
65
+ const UI_SCHEMA = `Presentation: you also emit a second object, a uiSchema, that mirrors the schema's property names. It controls looks only -- never put data or validation in it.
66
+
67
+ Per property (each "ui:KEY" may instead sit in "ui:options":{KEY:...} without the prefix):
68
+ - "ui:widget": "textarea" (multi-line; rows via "ui:options":{"rows":N}), "password" (masked), "radio" (show an enum as radio buttons), "hidden" (collected, not shown).
69
+ - "ui:help" (a hint line), "ui:placeholder", "ui:title"/"ui:description" (override the schema's), "ui:autofocus":true, "ui:readonly":true, "ui:options":{"label":false} (hide the label).
70
+ On the root: "ui:order":["a","b","*"] orders fields ("*" = everything else).
71
+ On a repeatable group: nest item presentation under "items"; lock the count with "ui:options":{"addable":false} or {"removable":false}.
72
+
73
+ Example pairing:
74
+ schema: {"type":"object","properties":{"pw":{"type":"string","title":"Password"},"bio":{"type":"string","title":"Bio"}}}
75
+ uiSchema: {"ui:order":["pw","bio"],"pw":{"ui:widget":"password"},"bio":{"ui:widget":"textarea","ui:options":{"rows":4}}}`
76
+
77
+ const INTRO = `You design a terminal form to gather information from a user.`
78
+
79
+ // Output contracts — one of these closes the prompt so the model knows the
80
+ // exact shape to return.
81
+ const SCHEMA_OUTPUT = `Output: emit ONLY the JSON Schema object. No prose, no markdown, no code fence.`
82
+ const BOTH_OUTPUT = `Output: emit TWO JSON objects, the JSON Schema first and the uiSchema second, separated by a line containing only ---. No prose, no code fence.`
83
+
84
+ // Assemble a system prompt from the layers you want.
85
+ // build() -> intro + CORE + schema-only output
86
+ // build({ nesting: true }) -> + NESTING
87
+ // build({ uiSchema: true }) -> + UI_SCHEMA + two-object output
88
+ // Pass { intro: false } to drop the framing line if you supply your own.
89
+ function build(opts = {}) {
90
+ const parts = []
91
+ if (opts.intro !== false) parts.push(INTRO)
92
+ parts.push(CORE)
93
+ if (opts.nesting) parts.push(NESTING)
94
+ if (opts.uiSchema) parts.push(UI_SCHEMA)
95
+ parts.push(opts.uiSchema ? BOTH_OUTPUT : SCHEMA_OUTPUT)
96
+ return parts.join('\n\n')
97
+ }
98
+
99
+ module.exports = {
100
+ build, // build({ nesting?, uiSchema?, intro? }) -> assembled system prompt
101
+ CORE, // flat object schema: types, titles, required, enums, validation
102
+ NESTING, // nested objects, arrays, oneOf/anyOf, if/then/else, const
103
+ UI_SCHEMA, // uiSchema presentation layer (implies the two-object output)
104
+ INTRO, // one-line framing sentence
105
+ SCHEMA_OUTPUT, // output contract: schema only
106
+ BOTH_OUTPUT // output contract: schema + uiSchema, --- separated
107
+ }