@rip-lang/script 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +395 -0
- package/package.json +38 -0
- package/script.rip +517 -0
package/README.md
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
<img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/assets/rip.png" style="width:50px" /> <br>
|
|
2
|
+
|
|
3
|
+
# Rip Script - @rip-lang/script
|
|
4
|
+
|
|
5
|
+
> **A homoiconic interaction engine — automate stateful conversations with remote systems using nested data structures**
|
|
6
|
+
|
|
7
|
+
Rip Script turns arrays of patterns and responses into fully automated
|
|
8
|
+
interactive sessions. It connects to a system via PTY, SSH, or TCP, then walks
|
|
9
|
+
a nested data structure — matching output, sending input, branching on patterns,
|
|
10
|
+
and recursing into sub-scripts. The data structure IS the program.
|
|
11
|
+
|
|
12
|
+
## Quick Start
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bun add @rip-lang/script
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```coffee
|
|
19
|
+
import Script from '@rip-lang/script'
|
|
20
|
+
|
|
21
|
+
chat = Script.spawn! 'bash'
|
|
22
|
+
|
|
23
|
+
result = chat! [
|
|
24
|
+
"$ ", "echo hello"
|
|
25
|
+
"hello", ""
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
chat.disconnect!
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## What It Does
|
|
32
|
+
|
|
33
|
+
Rip Script sits between your code and an interactive system, driving a
|
|
34
|
+
conversation through a PTY, SSH connection, or TCP socket:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
38
|
+
│ Rip Script │ chat! │ Engine │ PTY │ Remote │
|
|
39
|
+
│ (your code) │─────────▶│ (interpret) │◀────────▶│ (system) │
|
|
40
|
+
└──────────────┘ └──────────────┘ └──────────────┘
|
|
41
|
+
│ │
|
|
42
|
+
type-dispatch interactive
|
|
43
|
+
on nested data terminal I/O
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
You pass it an array. It processes each element by type:
|
|
47
|
+
|
|
48
|
+
- **Strings** alternate between *expect* (wait for this in output) and *send* (type this as input)
|
|
49
|
+
- **Regexes** match patterns in output with capture groups
|
|
50
|
+
- **Objects/Maps** branch on multiple possible prompts (first match wins)
|
|
51
|
+
- **Arrays** nest sub-scripts or execute conditionally
|
|
52
|
+
- **Functions** inject dynamic behavior — their return value becomes the next instruction
|
|
53
|
+
- **Symbols** control flow: `REDO`, `SKIP`, `ELSE`, `THIS`, `PURE`
|
|
54
|
+
|
|
55
|
+
## Connection Types
|
|
56
|
+
|
|
57
|
+
### PTY Spawn (Local Console)
|
|
58
|
+
|
|
59
|
+
Spawn a local process with a real pseudo-terminal:
|
|
60
|
+
|
|
61
|
+
```coffee
|
|
62
|
+
chat = Script.spawn! 'mumps -dir' # MUMPS console
|
|
63
|
+
chat = Script.spawn! 'bash' # local shell
|
|
64
|
+
chat = Script.spawn! 'python3', ['-i'] # interactive Python
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### SSH (Remote Systems)
|
|
68
|
+
|
|
69
|
+
Connect via SSH, using your `~/.ssh/config`, keys, and agent:
|
|
70
|
+
|
|
71
|
+
```coffee
|
|
72
|
+
chat = Script.ssh! 'admin@company.example.com'
|
|
73
|
+
chat = Script.ssh! 'ssh://user:pass@10.0.1.50:22'
|
|
74
|
+
chat = Script.ssh! 'user@host', slow: 30 # longer timeout for slow links
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### TCP (Raw Socket)
|
|
78
|
+
|
|
79
|
+
For telnet-style connections:
|
|
80
|
+
|
|
81
|
+
```coffee
|
|
82
|
+
chat = Script.tcp! '10.0.1.50', 23
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Auto-Detect from URL
|
|
86
|
+
|
|
87
|
+
```coffee
|
|
88
|
+
chat = Script.connect! 'ssh://user@host:22'
|
|
89
|
+
chat = Script.connect! 'tcp://10.0.1.50:23'
|
|
90
|
+
chat = Script.connect! 'spawn:bash'
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Trace Mode (Dry Run)
|
|
94
|
+
|
|
95
|
+
Log what a script would do without connecting:
|
|
96
|
+
|
|
97
|
+
```coffee
|
|
98
|
+
chat = Script.trace()
|
|
99
|
+
|
|
100
|
+
chat! [
|
|
101
|
+
">", "D ^XUP"
|
|
102
|
+
"Select OPTION:", "DG ADMIT PATIENT"
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
# Output:
|
|
106
|
+
# EXPECT: ">"
|
|
107
|
+
# SEND: "D ^XUP"
|
|
108
|
+
# EXPECT: "Select OPTION:"
|
|
109
|
+
# SEND: "DG ADMIT PATIENT"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## The Chat Engine
|
|
113
|
+
|
|
114
|
+
### Alternating Expect/Send
|
|
115
|
+
|
|
116
|
+
The simplest pattern — strings alternate between waiting and sending:
|
|
117
|
+
|
|
118
|
+
```coffee
|
|
119
|
+
chat! [
|
|
120
|
+
">", "D ^XUP" # wait for ">", send "D ^XUP"
|
|
121
|
+
"Select OPTION:", "DG ADMIT PATIENT" # wait for prompt, send menu choice
|
|
122
|
+
"Admit PATIENT:", "SMITH,JOHN" # wait for prompt, send patient name
|
|
123
|
+
]
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Regex Matching
|
|
127
|
+
|
|
128
|
+
Use regexes for flexible pattern matching with captures:
|
|
129
|
+
|
|
130
|
+
```coffee
|
|
131
|
+
result = chat! [
|
|
132
|
+
">", "W $ZV"
|
|
133
|
+
/Version: (\d+\.\d+)/ # match and capture version number
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
version = chat.last # "1.2" — the first capture group
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Object Multiplexing (Branching)
|
|
140
|
+
|
|
141
|
+
Objects try each key against the output buffer — first match wins:
|
|
142
|
+
|
|
143
|
+
```coffee
|
|
144
|
+
chat! [
|
|
145
|
+
"Enter name:", "SMITH,JOHN"
|
|
146
|
+
{
|
|
147
|
+
"SURE YOU WANT TO ADD": ["Y"] # if confirmation, say yes
|
|
148
|
+
"Select ADMISSION DATE:": [""] # if date prompt, press enter
|
|
149
|
+
"Do you want to continue": ["C"] # if continue prompt, continue
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Regex-Keyed Multiplexing with `mux()`
|
|
155
|
+
|
|
156
|
+
When you need regex keys, use `mux()` to build a Map:
|
|
157
|
+
|
|
158
|
+
```coffee
|
|
159
|
+
import { mux, ELSE } from '@rip-lang/script'
|
|
160
|
+
|
|
161
|
+
chat! [
|
|
162
|
+
"Enter name:", "SMITH,JOHN"
|
|
163
|
+
mux(
|
|
164
|
+
/^NAME:/, [""] # regex key
|
|
165
|
+
"CHOOSE 1", [1] # string key
|
|
166
|
+
ELSE, null # fallback — nothing matched
|
|
167
|
+
)
|
|
168
|
+
]
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Conditional Arrays
|
|
172
|
+
|
|
173
|
+
Arrays with a boolean first element execute conditionally:
|
|
174
|
+
|
|
175
|
+
```coffee
|
|
176
|
+
chat! [
|
|
177
|
+
"DIVISION:", data.division
|
|
178
|
+
[data.hasBeds # only if hasBeds is true
|
|
179
|
+
"NUMBER OF BEDS:", data.beds
|
|
180
|
+
"SERIOUSLY ILL:", "N"
|
|
181
|
+
]
|
|
182
|
+
"Select WARD:", ""
|
|
183
|
+
]
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Sub-Scripts
|
|
187
|
+
|
|
188
|
+
Arrays without a boolean first element are nested sub-scripts:
|
|
189
|
+
|
|
190
|
+
```coffee
|
|
191
|
+
chat! [
|
|
192
|
+
"Select OPTION:", "EDIT"
|
|
193
|
+
[ # nested conversation
|
|
194
|
+
"FIELD:", "NAME"
|
|
195
|
+
"FIELD:", "TITLE"
|
|
196
|
+
"FIELD:", ""
|
|
197
|
+
]
|
|
198
|
+
"Select OPTION:", ""
|
|
199
|
+
]
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### Function Callbacks
|
|
203
|
+
|
|
204
|
+
Functions inject dynamic behavior. Their return value becomes the next item:
|
|
205
|
+
|
|
206
|
+
```coffee
|
|
207
|
+
chat! [
|
|
208
|
+
"Select KEY:", ->
|
|
209
|
+
for key in keys
|
|
210
|
+
chat! [
|
|
211
|
+
"Select KEY:", key
|
|
212
|
+
{ "KEY:": [""], "REVIEW DATE:": "" }
|
|
213
|
+
]
|
|
214
|
+
true # continue to next item
|
|
215
|
+
|
|
216
|
+
"Select KEY:", ""
|
|
217
|
+
]
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Functions receive the last matched value:
|
|
221
|
+
|
|
222
|
+
```coffee
|
|
223
|
+
chat! [
|
|
224
|
+
/Version: (\S+)/, (fullMatch, version) ->
|
|
225
|
+
p "Running version #{version}"
|
|
226
|
+
true
|
|
227
|
+
]
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Return Values
|
|
231
|
+
|
|
232
|
+
`chat!` returns the last matched value — use it to extract data:
|
|
233
|
+
|
|
234
|
+
```coffee
|
|
235
|
+
pair = chat! [
|
|
236
|
+
">", "D GETENV^%ZOSV W Y"
|
|
237
|
+
/\n([^\n]+)\n/
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
systemInfo = pair[1] # the captured group
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Control Flow
|
|
244
|
+
|
|
245
|
+
```coffee
|
|
246
|
+
import { REDO, SKIP, ELSE, THIS, PURE } from '@rip-lang/script'
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
| Constant | Purpose |
|
|
250
|
+
|----------|---------|
|
|
251
|
+
| `REDO` | Re-enter the current multiplexer (after reading more data) |
|
|
252
|
+
| `SKIP` | Skip the current item, continue to next |
|
|
253
|
+
| `ELSE` | Fallback key in multiplexers — fires when no other key matches |
|
|
254
|
+
| `THIS` | In a multiplexer value, return the matched text itself |
|
|
255
|
+
| `PURE` | Raw mode — no line terminator, no ANSI stripping |
|
|
256
|
+
|
|
257
|
+
## Helper Functions
|
|
258
|
+
|
|
259
|
+
### `mux(...args)` — Mixed-Key Multiplexer
|
|
260
|
+
|
|
261
|
+
Build a Map from alternating key/value arguments:
|
|
262
|
+
|
|
263
|
+
```coffee
|
|
264
|
+
mux(
|
|
265
|
+
/^NAME:/, [""]
|
|
266
|
+
"CHOOSE 1", [1]
|
|
267
|
+
ELSE, null
|
|
268
|
+
)
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### `replace(value)` — Replace/Edit Handler
|
|
272
|
+
|
|
273
|
+
Handle a "Replace ... With ..." editing pattern:
|
|
274
|
+
|
|
275
|
+
```coffee
|
|
276
|
+
chat! [
|
|
277
|
+
"OUTPATIENT EXPANSION:", replace(data.description)
|
|
278
|
+
]
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### `quote(value)` — Exact Match
|
|
282
|
+
|
|
283
|
+
Wrap in double quotes for forced exact matching:
|
|
284
|
+
|
|
285
|
+
```coffee
|
|
286
|
+
chat! [
|
|
287
|
+
"Select DRUG:", quote(data.drugName)
|
|
288
|
+
]
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### `prompts(obj)` — Prompt/Response Sugar
|
|
292
|
+
|
|
293
|
+
Shorthand for common prompt-response objects:
|
|
294
|
+
|
|
295
|
+
```coffee
|
|
296
|
+
chat! [
|
|
297
|
+
prompts
|
|
298
|
+
"Select KEY:": [key]
|
|
299
|
+
" KEY:": [""]
|
|
300
|
+
"REVIEW DATE:": ""
|
|
301
|
+
]
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### `enter(value, extra)` — Add-If-New Handler
|
|
305
|
+
|
|
306
|
+
Handle entries that may trigger "Are you adding?" confirmation:
|
|
307
|
+
|
|
308
|
+
```coffee
|
|
309
|
+
chat! [
|
|
310
|
+
"Select WARD:", enter(data.ward)
|
|
311
|
+
]
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## Connection Options
|
|
315
|
+
|
|
316
|
+
All connection factories accept options:
|
|
317
|
+
|
|
318
|
+
```coffee
|
|
319
|
+
chat = Script.ssh! 'user@host',
|
|
320
|
+
live: true # print received data to stdout (default: true)
|
|
321
|
+
echo: false # print sent data to stdout (default: false)
|
|
322
|
+
show: false # print matched data to stdout (default: false)
|
|
323
|
+
slow: 10 # timeout in seconds waiting for output (default: 10)
|
|
324
|
+
fast: 0.25 # timeout in seconds for "is there more?" (default: 0.25)
|
|
325
|
+
bomb: true # throw on timeout (default: true)
|
|
326
|
+
line: "\r" # line terminator appended to sends (default: "\r")
|
|
327
|
+
ansi: false # keep ANSI escapes (default: false = strip them)
|
|
328
|
+
nocr: true # strip \r characters (default: true)
|
|
329
|
+
wait: null # [min, max] random delay in seconds before sends
|
|
330
|
+
auth: [...] # initial authentication script to run on connect
|
|
331
|
+
init: [...] # initialization script to run after auth
|
|
332
|
+
onSend: null # (text) -> hook called after each send
|
|
333
|
+
onRecv: null # (data) -> hook called after each read
|
|
334
|
+
onMatch: null # (pattern, matched) -> hook called after each match
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## Options Reference
|
|
338
|
+
|
|
339
|
+
| Option | Default | Description |
|
|
340
|
+
|--------|---------|-------------|
|
|
341
|
+
| `live` | `true` | Print received data to stdout in real time |
|
|
342
|
+
| `echo` | `false` | Print sent data to stdout |
|
|
343
|
+
| `show` | `false` | Print matched/consumed text to stdout |
|
|
344
|
+
| `slow` | `10` | Seconds to wait before timeout |
|
|
345
|
+
| `fast` | `0.25` | Seconds for "is there more data?" check |
|
|
346
|
+
| `bomb` | `true` | Throw on timeout (false = return silently) |
|
|
347
|
+
| `line` | `"\r"` | Line terminator appended to every send |
|
|
348
|
+
| `ansi` | `false` | Keep ANSI escape sequences (false = strip) |
|
|
349
|
+
| `nocr` | `true` | Strip carriage returns from received data |
|
|
350
|
+
| `wait` | `null` | `[min, max]` random delay before sends (seconds) |
|
|
351
|
+
| `auth` | `null` | Script array to run on connect (authentication) |
|
|
352
|
+
| `init` | `null` | Script array to run after auth (initialization) |
|
|
353
|
+
|
|
354
|
+
## Type Dispatch Reference
|
|
355
|
+
|
|
356
|
+
| Type | Role | Behavior |
|
|
357
|
+
|------|------|----------|
|
|
358
|
+
| `String` / `Number` | Match or Send | Listen mode: wait for string in output. Talk mode: send to system. |
|
|
359
|
+
| `RegExp` | Pattern Match | Match against output buffer, captures available. |
|
|
360
|
+
| `null` | Mode Toggle | Flip between listen and talk modes. |
|
|
361
|
+
| `true` | Continue | No-op pass-through. |
|
|
362
|
+
| `false` / `Symbol` | Control Signal | `REDO`, `SKIP`, etc. — flow control. |
|
|
363
|
+
| `Object` | Multiplexer | Try each string key against buffer. First match wins. |
|
|
364
|
+
| `Map` | Multiplexer | Like Object but supports regex keys. Use `mux()`. |
|
|
365
|
+
| `Array` | Sub-script | Nest a conversation. Boolean first element = conditional. |
|
|
366
|
+
| `Function` | Callback | Execute, return value becomes next item to process. |
|
|
367
|
+
|
|
368
|
+
## How It Works
|
|
369
|
+
|
|
370
|
+
Rip Script is built from a single file:
|
|
371
|
+
|
|
372
|
+
| File | Role |
|
|
373
|
+
|------|------|
|
|
374
|
+
| `script.rip` | Transports (Spawn, TCP, Trace), Engine (chat interpreter), Script (factory), helpers |
|
|
375
|
+
|
|
376
|
+
The engine is a recursive type-dispatching interpreter. It maintains a buffer of
|
|
377
|
+
received data and alternates between two modes: **listen** (searching the buffer
|
|
378
|
+
for patterns) and **talk** (sending responses). Each element in the script array
|
|
379
|
+
is dispatched by its JavaScript type — strings, regexes, objects, arrays, and
|
|
380
|
+
functions each have fixed, well-defined roles. This makes the data structure
|
|
381
|
+
simultaneously readable by humans and executable by the engine.
|
|
382
|
+
|
|
383
|
+
The `chat!` callable is an async function with utility methods attached.
|
|
384
|
+
`Script.ssh!` et al. create a transport, wrap it in an engine, and return
|
|
385
|
+
the callable — so the variable name itself becomes the verb.
|
|
386
|
+
|
|
387
|
+
## Requirements
|
|
388
|
+
|
|
389
|
+
- **Bun** 1.3.5+ (for native PTY support via `Bun.Terminal`)
|
|
390
|
+
- **rip-lang** 3.x (installed automatically as a dependency)
|
|
391
|
+
- **ssh** binary on PATH (for SSH connections)
|
|
392
|
+
|
|
393
|
+
## License
|
|
394
|
+
|
|
395
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rip-lang/script",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Homoiconic interaction engine — automate stateful conversations with remote systems using nested data structures",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "script.rip",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./script.rip"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "rip test/script.test.rip"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"rip",
|
|
15
|
+
"expect",
|
|
16
|
+
"automation",
|
|
17
|
+
"interactive",
|
|
18
|
+
"pty",
|
|
19
|
+
"ssh",
|
|
20
|
+
"terminal",
|
|
21
|
+
"scripting",
|
|
22
|
+
"homoiconic"
|
|
23
|
+
],
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/shreeve/rip-lang.git",
|
|
27
|
+
"directory": "packages/script"
|
|
28
|
+
},
|
|
29
|
+
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"rip-lang": ">=3.13.126"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"script.rip",
|
|
36
|
+
"README.md"
|
|
37
|
+
]
|
|
38
|
+
}
|
package/script.rip
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# @rip-lang/script — Homoiconic interaction engine for Rip
|
|
3
|
+
#
|
|
4
|
+
# Author: Steve Shreeve (steve.shreeve@gmail.com)
|
|
5
|
+
# Date: March 20, 2026
|
|
6
|
+
#
|
|
7
|
+
# A type-dispatching automation engine that scripts complex stateful interactions
|
|
8
|
+
# with remote systems. Pass it nested arrays of patterns and responses — strings,
|
|
9
|
+
# regexes, objects, functions — and it walks the structure, matching output and
|
|
10
|
+
# sending input. The data structure IS the program.
|
|
11
|
+
# ==============================================================================
|
|
12
|
+
|
|
13
|
+
# ==[ Control Flow Constants ]==
|
|
14
|
+
|
|
15
|
+
export REDO =! Symbol 'redo'
|
|
16
|
+
export SKIP =! Symbol 'skip'
|
|
17
|
+
export ELSE =! Symbol 'else'
|
|
18
|
+
export THIS =! Symbol 'this'
|
|
19
|
+
export PURE =! Symbol 'pure'
|
|
20
|
+
|
|
21
|
+
# ==[ Helpers ]==
|
|
22
|
+
|
|
23
|
+
ANSI_RE =! /\x08|\x1b[=>]|\x1b\[(?:[^a-zA-Z]*)[a-zA-Z]/g
|
|
24
|
+
|
|
25
|
+
def stripAnsi(str)
|
|
26
|
+
str.replace ANSI_RE, ''
|
|
27
|
+
|
|
28
|
+
def invoke(fn, back)
|
|
29
|
+
if Array.isArray(back) then fn(...back) else fn(back)
|
|
30
|
+
|
|
31
|
+
# ==[ SpawnTransport — PTY via Bun.Terminal ]==
|
|
32
|
+
|
|
33
|
+
class SpawnTransport
|
|
34
|
+
constructor: (cmd, args = [], opts = {}) ->
|
|
35
|
+
@queue = []
|
|
36
|
+
@waiting = null
|
|
37
|
+
@closed = false
|
|
38
|
+
@proc = Bun.spawn [cmd, ...args],
|
|
39
|
+
terminal:
|
|
40
|
+
cols: opts.cols ?? 80
|
|
41
|
+
rows: opts.rows ?? 24
|
|
42
|
+
data: (term, raw) =>
|
|
43
|
+
chunk = new TextDecoder().decode(raw)
|
|
44
|
+
if @waiting
|
|
45
|
+
fn = @waiting
|
|
46
|
+
@waiting = null
|
|
47
|
+
fn chunk
|
|
48
|
+
else
|
|
49
|
+
@queue.push chunk
|
|
50
|
+
|
|
51
|
+
write: (data) ->
|
|
52
|
+
@proc.terminal.write data
|
|
53
|
+
|
|
54
|
+
read: (timeout) ->
|
|
55
|
+
return null if @closed
|
|
56
|
+
return @queue.shift() if @queue.length > 0
|
|
57
|
+
new Promise (resolve) =>
|
|
58
|
+
timer = setTimeout =>
|
|
59
|
+
@waiting = null
|
|
60
|
+
resolve null
|
|
61
|
+
, timeout * 1000
|
|
62
|
+
@waiting = (data) ->
|
|
63
|
+
clearTimeout timer
|
|
64
|
+
resolve data
|
|
65
|
+
|
|
66
|
+
close: ->
|
|
67
|
+
@closed = true
|
|
68
|
+
try @proc.terminal.close()
|
|
69
|
+
try @proc.kill()
|
|
70
|
+
|
|
71
|
+
# ==[ TcpTransport — Raw TCP via Bun.connect ]==
|
|
72
|
+
|
|
73
|
+
class TcpTransport
|
|
74
|
+
constructor: (host, port, opts = {}) ->
|
|
75
|
+
@queue = []
|
|
76
|
+
@waiting = null
|
|
77
|
+
@closed = false
|
|
78
|
+
@ready = new Promise (resolve, reject) =>
|
|
79
|
+
Bun.connect
|
|
80
|
+
hostname: host
|
|
81
|
+
port: port
|
|
82
|
+
socket:
|
|
83
|
+
open: (socket) =>
|
|
84
|
+
@socket = socket
|
|
85
|
+
resolve()
|
|
86
|
+
data: (socket, raw) =>
|
|
87
|
+
chunk = if typeof raw is 'string' then raw else new TextDecoder().decode(raw)
|
|
88
|
+
if @waiting
|
|
89
|
+
fn = @waiting
|
|
90
|
+
@waiting = null
|
|
91
|
+
fn chunk
|
|
92
|
+
else
|
|
93
|
+
@queue.push chunk
|
|
94
|
+
close: =>
|
|
95
|
+
@closed = true
|
|
96
|
+
error: (socket, err) =>
|
|
97
|
+
@closed = true
|
|
98
|
+
reject err
|
|
99
|
+
|
|
100
|
+
write: (data) ->
|
|
101
|
+
@socket.write data
|
|
102
|
+
|
|
103
|
+
read: (timeout) ->
|
|
104
|
+
return null if @closed
|
|
105
|
+
return @queue.shift() if @queue.length > 0
|
|
106
|
+
new Promise (resolve) =>
|
|
107
|
+
timer = setTimeout =>
|
|
108
|
+
@waiting = null
|
|
109
|
+
resolve null
|
|
110
|
+
, timeout * 1000
|
|
111
|
+
@waiting = (data) ->
|
|
112
|
+
clearTimeout timer
|
|
113
|
+
resolve data
|
|
114
|
+
|
|
115
|
+
close: ->
|
|
116
|
+
@closed = true
|
|
117
|
+
try @socket.end()
|
|
118
|
+
|
|
119
|
+
# ==[ TraceTransport — Dry-run mode, no connection ]==
|
|
120
|
+
|
|
121
|
+
class TraceTransport
|
|
122
|
+
constructor: ->
|
|
123
|
+
@closed = false
|
|
124
|
+
|
|
125
|
+
write: (data) ->
|
|
126
|
+
process.stdout.write " SEND: #{JSON.stringify(data)}\n"
|
|
127
|
+
|
|
128
|
+
read: (timeout) ->
|
|
129
|
+
null
|
|
130
|
+
|
|
131
|
+
close: ->
|
|
132
|
+
@closed = true
|
|
133
|
+
|
|
134
|
+
# ==[ Engine — The Core Interpreter ]==
|
|
135
|
+
|
|
136
|
+
class Engine
|
|
137
|
+
constructor: (@transport, @opts = {}) ->
|
|
138
|
+
@buff = ''
|
|
139
|
+
@last = null
|
|
140
|
+
@live = @opts.live ?? true
|
|
141
|
+
@show = @opts.show ?? false
|
|
142
|
+
@echo = @opts.echo ?? false
|
|
143
|
+
@nocr = @opts.nocr ?? true
|
|
144
|
+
@ansi = @opts.ansi ?? false
|
|
145
|
+
@bomb = @opts.bomb ?? true
|
|
146
|
+
@slow = @opts.slow ?? 10
|
|
147
|
+
@fast = @opts.fast ?? 0.25
|
|
148
|
+
@line = @opts.line ?? "\r"
|
|
149
|
+
@wait = @opts.wait ?? null
|
|
150
|
+
@trace = @transport instanceof TraceTransport
|
|
151
|
+
|
|
152
|
+
# -- Read from transport, strip ANSI/CR, append to buffer --
|
|
153
|
+
|
|
154
|
+
read: (fast = false) ->
|
|
155
|
+
timeout = if fast then @fast else @slow
|
|
156
|
+
data = @transport.read! timeout
|
|
157
|
+
unless data?
|
|
158
|
+
return 'fast' if fast
|
|
159
|
+
raise "Timeout waiting for data\nBuffer: #{JSON.stringify(@buff)}" if @bomb
|
|
160
|
+
return 'slow'
|
|
161
|
+
data .= replace /\r/g, '' if @nocr
|
|
162
|
+
data = stripAnsi(data) unless @ansi
|
|
163
|
+
process.stdout.write(data) if @live
|
|
164
|
+
@opts.onRecv?(data)
|
|
165
|
+
@buff += data
|
|
166
|
+
|
|
167
|
+
# -- Send text with line terminator --
|
|
168
|
+
|
|
169
|
+
send: (text) ->
|
|
170
|
+
return unless text?
|
|
171
|
+
text = String(text)
|
|
172
|
+
if @wait
|
|
173
|
+
ms = (@wait[0] + Math.random() * (@wait[1] - @wait[0])) * 1000
|
|
174
|
+
Bun.sleep! ms
|
|
175
|
+
@transport.write text + @line
|
|
176
|
+
process.stdout.write(text) if @echo
|
|
177
|
+
@opts.onSend?(text)
|
|
178
|
+
text
|
|
179
|
+
|
|
180
|
+
# -- Disconnect --
|
|
181
|
+
|
|
182
|
+
disconnect: ->
|
|
183
|
+
@transport.close()
|
|
184
|
+
|
|
185
|
+
# -- Dispatch on a matched value in a multiplexer --
|
|
186
|
+
|
|
187
|
+
dispatchVal: (val, back) ->
|
|
188
|
+
if typeof val is 'string' or typeof val is 'number'
|
|
189
|
+
@send! String(val)
|
|
190
|
+
back
|
|
191
|
+
else if Array.isArray(val)
|
|
192
|
+
back = @chat!(null, ...val) unless val.length is 0
|
|
193
|
+
back = REDO if val.length <= 1
|
|
194
|
+
back
|
|
195
|
+
else if typeof val is 'function'
|
|
196
|
+
back = invoke! val, back
|
|
197
|
+
if Array.isArray(back)
|
|
198
|
+
if back.length > 0 and back[0] is PURE
|
|
199
|
+
savedLine = @line
|
|
200
|
+
@line = ''
|
|
201
|
+
back = @chat!(null, ...back.slice(1)) unless back.length is 1
|
|
202
|
+
@line = savedLine
|
|
203
|
+
back = REDO if back.length <= 2
|
|
204
|
+
else
|
|
205
|
+
back = @chat!(null, ...back) unless back.length is 0
|
|
206
|
+
back = REDO if back.length <= 1
|
|
207
|
+
back
|
|
208
|
+
else if val instanceof Map or (typeof val is 'object' and val isnt null and not Array.isArray(val))
|
|
209
|
+
@chat! val
|
|
210
|
+
else if val is true or val is null
|
|
211
|
+
val
|
|
212
|
+
else if val is false or typeof val is 'symbol'
|
|
213
|
+
if val is THIS
|
|
214
|
+
if Array.isArray(back) then back[0] else back
|
|
215
|
+
else
|
|
216
|
+
val
|
|
217
|
+
else
|
|
218
|
+
back
|
|
219
|
+
|
|
220
|
+
# -- The Chat Engine: recursive type-dispatching interpreter --
|
|
221
|
+
#
|
|
222
|
+
# Processes a flat list of items. Maintains two modes:
|
|
223
|
+
# listen (talk=false): wait for patterns in the buffer
|
|
224
|
+
# talk (talk=true): send responses to the stream
|
|
225
|
+
#
|
|
226
|
+
# Type dispatch:
|
|
227
|
+
# null → toggle mode
|
|
228
|
+
# true → no-op continue
|
|
229
|
+
# false / Symbol → control signal (REDO, SKIP, etc.)
|
|
230
|
+
# String/Number → match (listen) or send (talk)
|
|
231
|
+
# RegExp → pattern match against buffer
|
|
232
|
+
# Object → multiplexer (string keys, first match wins)
|
|
233
|
+
# Map → multiplexer (any key type, for regex keys)
|
|
234
|
+
# Array → sub-script; [boolean, ...] for conditional; [PURE, ...] for raw mode
|
|
235
|
+
# Function → callback, return value becomes next item
|
|
236
|
+
|
|
237
|
+
chat: (...args) ->
|
|
238
|
+
list = if args.length is 1 and Array.isArray(args[0]) then args[0] else args
|
|
239
|
+
return null if list.length is 0
|
|
240
|
+
|
|
241
|
+
back = null
|
|
242
|
+
talk = false
|
|
243
|
+
fast = false
|
|
244
|
+
|
|
245
|
+
for entry in list
|
|
246
|
+
item = entry
|
|
247
|
+
|
|
248
|
+
done = false
|
|
249
|
+
until done
|
|
250
|
+
done = true
|
|
251
|
+
type = kind(item)
|
|
252
|
+
|
|
253
|
+
# -- null: toggle listen/talk mode --
|
|
254
|
+
if type is 'null'
|
|
255
|
+
talk = !talk
|
|
256
|
+
back = null
|
|
257
|
+
|
|
258
|
+
# -- boolean: true continues, false propagates --
|
|
259
|
+
else if type is 'boolean'
|
|
260
|
+
back = item
|
|
261
|
+
return back unless item
|
|
262
|
+
|
|
263
|
+
# -- symbol: control signal --
|
|
264
|
+
else if type is 'symbol'
|
|
265
|
+
back = item
|
|
266
|
+
return back unless back is REDO
|
|
267
|
+
|
|
268
|
+
# -- string / number: match or send --
|
|
269
|
+
else if type is 'string' or type is 'number'
|
|
270
|
+
str = String(item)
|
|
271
|
+
if talk
|
|
272
|
+
@send! str
|
|
273
|
+
back = str
|
|
274
|
+
talk = false
|
|
275
|
+
else if @trace
|
|
276
|
+
process.stdout.write "EXPECT: #{JSON.stringify(str)}\n"
|
|
277
|
+
back = str
|
|
278
|
+
talk = true
|
|
279
|
+
else
|
|
280
|
+
idx = @buff.indexOf str
|
|
281
|
+
if idx >= 0
|
|
282
|
+
@last = str
|
|
283
|
+
back = @buff.slice 0, idx + str.length
|
|
284
|
+
@buff = @buff.slice idx + str.length
|
|
285
|
+
process.stdout.write(back.replace(/\r/g, '')) if @show
|
|
286
|
+
@opts.onMatch?(str, back)
|
|
287
|
+
talk = true
|
|
288
|
+
else
|
|
289
|
+
@read!()
|
|
290
|
+
done = false
|
|
291
|
+
|
|
292
|
+
# -- regexp: pattern match --
|
|
293
|
+
else if type is 'regexp'
|
|
294
|
+
if @trace
|
|
295
|
+
process.stdout.write "EXPECT: #{item}\n"
|
|
296
|
+
back = ['']
|
|
297
|
+
talk = true
|
|
298
|
+
else
|
|
299
|
+
match = @buff.match item
|
|
300
|
+
if match
|
|
301
|
+
@last = match[1] ?? match[0]
|
|
302
|
+
pre = @buff.slice 0, match.index
|
|
303
|
+
@buff = @buff.slice match.index + match[0].length
|
|
304
|
+
back = [pre + match[0], ...match.slice(1)]
|
|
305
|
+
process.stdout.write(back[0].replace(/\r/g, '')) if @show
|
|
306
|
+
@opts.onMatch?(item, back)
|
|
307
|
+
talk = true
|
|
308
|
+
else
|
|
309
|
+
talk = false
|
|
310
|
+
@read!()
|
|
311
|
+
done = false
|
|
312
|
+
|
|
313
|
+
# -- map / object: multiplexer --
|
|
314
|
+
else if type is 'map' or type is 'object'
|
|
315
|
+
entries = if item instanceof Map then [...item.entries()] else Object.entries(item)
|
|
316
|
+
hasElse = entries.some ([k]) -> k is ELSE or k is 'else'
|
|
317
|
+
elseVal = null
|
|
318
|
+
for [ek, ev] in entries
|
|
319
|
+
elseVal = ev if ek is ELSE or ek is 'else'
|
|
320
|
+
|
|
321
|
+
matched = false
|
|
322
|
+
|
|
323
|
+
if @trace
|
|
324
|
+
keys = entries.map(([k]) -> String(k)).filter((k) -> k isnt 'else')
|
|
325
|
+
process.stdout.write "BRANCH: [#{keys.join(', ')}]\n"
|
|
326
|
+
matched = true
|
|
327
|
+
talk = false
|
|
328
|
+
back = null
|
|
329
|
+
else if fast and elseVal?
|
|
330
|
+
matched = true
|
|
331
|
+
back = ELSE
|
|
332
|
+
@dispatchVal! elseVal, back
|
|
333
|
+
else
|
|
334
|
+
for [key, val] in entries
|
|
335
|
+
continue if key is ELSE or key is 'else'
|
|
336
|
+
ktype = kind(key)
|
|
337
|
+
|
|
338
|
+
if ktype is 'string' or ktype is 'number'
|
|
339
|
+
keyStr = String(key)
|
|
340
|
+
idx = @buff.indexOf keyStr
|
|
341
|
+
if idx >= 0
|
|
342
|
+
back = @buff.slice 0, idx + keyStr.length
|
|
343
|
+
@buff = @buff.slice idx + keyStr.length
|
|
344
|
+
process.stdout.write(back.replace(/\r/g, '')) if @show
|
|
345
|
+
matched = true
|
|
346
|
+
else
|
|
347
|
+
continue
|
|
348
|
+
else if ktype is 'regexp'
|
|
349
|
+
m = @buff.match key
|
|
350
|
+
if m
|
|
351
|
+
pre = @buff.slice 0, m.index
|
|
352
|
+
@buff = @buff.slice m.index + m[0].length
|
|
353
|
+
back = [pre + m[0], ...m.slice(1)]
|
|
354
|
+
process.stdout.write(back[0].replace(/\r/g, '')) if @show
|
|
355
|
+
matched = true
|
|
356
|
+
else
|
|
357
|
+
continue
|
|
358
|
+
else
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
if matched
|
|
362
|
+
back = @dispatchVal! val, back
|
|
363
|
+
break
|
|
364
|
+
|
|
365
|
+
unless matched
|
|
366
|
+
fast = @read!(hasElse) is 'fast'
|
|
367
|
+
done = false
|
|
368
|
+
else
|
|
369
|
+
fast = false
|
|
370
|
+
talk = false
|
|
371
|
+
done = true
|
|
372
|
+
done = false if back is REDO
|
|
373
|
+
|
|
374
|
+
# -- array: sub-script or conditional --
|
|
375
|
+
else if type is 'array'
|
|
376
|
+
if item.length > 0 and item[0] is PURE
|
|
377
|
+
savedLine = @line
|
|
378
|
+
savedAnsi = @ansi
|
|
379
|
+
@line = ''
|
|
380
|
+
@ansi = false
|
|
381
|
+
back = if talk then @chat!(null, ...item.slice(1)) else @chat!(...item.slice(1))
|
|
382
|
+
@line = savedLine
|
|
383
|
+
@ansi = savedAnsi
|
|
384
|
+
else if item.length > 0 and kind(item[0]) is 'boolean'
|
|
385
|
+
if item[0]
|
|
386
|
+
back = if talk then @chat!(null, ...item.slice(1)) else @chat!(...item.slice(1))
|
|
387
|
+
else
|
|
388
|
+
back = if talk then @chat!(null, ...item) else @chat!(...item)
|
|
389
|
+
talk = false
|
|
390
|
+
|
|
391
|
+
# -- function: callback --
|
|
392
|
+
else if type is 'function' or type is 'asyncfunction'
|
|
393
|
+
back = invoke! item, back
|
|
394
|
+
item = back unless back is REDO
|
|
395
|
+
done = false
|
|
396
|
+
|
|
397
|
+
# -- unknown --
|
|
398
|
+
else
|
|
399
|
+
raise "Script can't handle #{type}: #{JSON.stringify(item)}"
|
|
400
|
+
|
|
401
|
+
# Between items: check control signals
|
|
402
|
+
break if back is REDO
|
|
403
|
+
break if back is SKIP
|
|
404
|
+
|
|
405
|
+
back
|
|
406
|
+
|
|
407
|
+
# ==[ Script — Public API Factory ]==
|
|
408
|
+
|
|
409
|
+
def createChat(engine)
|
|
410
|
+
fn = (list) -> engine.chat(list)
|
|
411
|
+
fn.send = (text) -> engine.send(text)
|
|
412
|
+
fn.read = (fast) -> engine.read(fast)
|
|
413
|
+
fn.disconnect = -> engine.disconnect()
|
|
414
|
+
Object.defineProperty fn, 'buffer', get: -> engine.buff
|
|
415
|
+
Object.defineProperty fn, 'last', get: -> engine.last
|
|
416
|
+
fn
|
|
417
|
+
|
|
418
|
+
export class Script
|
|
419
|
+
|
|
420
|
+
@spawn: (cmd, args, opts) ->
|
|
421
|
+
if typeof args is 'object' and not Array.isArray(args)
|
|
422
|
+
opts = args
|
|
423
|
+
args = []
|
|
424
|
+
opts ??= {}
|
|
425
|
+
transport = new SpawnTransport(cmd, args ?? [], opts)
|
|
426
|
+
engine = new Engine(transport, opts)
|
|
427
|
+
if opts.auth
|
|
428
|
+
engine.chat! opts.auth
|
|
429
|
+
if opts.init
|
|
430
|
+
engine.chat! opts.init
|
|
431
|
+
createChat engine
|
|
432
|
+
|
|
433
|
+
@ssh: (url, opts = {}) ->
|
|
434
|
+
parsed = new URL(if url.includes('://') then url else "ssh://#{url}")
|
|
435
|
+
args = []
|
|
436
|
+
args.push '-p', parsed.port if parsed.port
|
|
437
|
+
args.push '-l', decodeURIComponent(parsed.username) if parsed.username
|
|
438
|
+
args.push parsed.hostname
|
|
439
|
+
transport = new SpawnTransport('ssh', args, opts)
|
|
440
|
+
engine = new Engine(transport, opts)
|
|
441
|
+
if parsed.password
|
|
442
|
+
engine.chat! [/[Pp]assword/, decodeURIComponent(parsed.password)]
|
|
443
|
+
if opts.auth
|
|
444
|
+
engine.chat! opts.auth
|
|
445
|
+
if opts.init
|
|
446
|
+
engine.chat! opts.init
|
|
447
|
+
createChat engine
|
|
448
|
+
|
|
449
|
+
@tcp: (host, port, opts = {}) ->
|
|
450
|
+
transport = new TcpTransport(host, port, opts)
|
|
451
|
+
transport.ready!
|
|
452
|
+
engine = new Engine(transport, opts)
|
|
453
|
+
if opts.auth
|
|
454
|
+
engine.chat! opts.auth
|
|
455
|
+
if opts.init
|
|
456
|
+
engine.chat! opts.init
|
|
457
|
+
createChat engine
|
|
458
|
+
|
|
459
|
+
@connect: (url, opts = {}) ->
|
|
460
|
+
[scheme] = url.split '://'
|
|
461
|
+
switch scheme
|
|
462
|
+
when 'spawn' then Script.spawn! url.slice(8), [], opts
|
|
463
|
+
when 'ssh' then Script.ssh! url, opts
|
|
464
|
+
when 'tcp' then Script.tcp! url.split('://')[1].split(':')[0], parseInt(url.split(':').pop()), opts
|
|
465
|
+
else raise "Unknown scheme: #{scheme}"
|
|
466
|
+
|
|
467
|
+
@trace: (opts = {}) ->
|
|
468
|
+
transport = new TraceTransport()
|
|
469
|
+
engine = new Engine(transport, { ...opts, live: false })
|
|
470
|
+
createChat engine
|
|
471
|
+
|
|
472
|
+
# ==[ Helper Functions ]==
|
|
473
|
+
|
|
474
|
+
# Build a Map from alternating key/value arguments (supports regex keys)
|
|
475
|
+
export def mux(...args)
|
|
476
|
+
pairs = []
|
|
477
|
+
i = 0
|
|
478
|
+
while i < args.length
|
|
479
|
+
pairs.push [args[i], args[i + 1]]
|
|
480
|
+
i += 2
|
|
481
|
+
new Map pairs
|
|
482
|
+
|
|
483
|
+
# Sugar for common prompt/response patterns (string keys only)
|
|
484
|
+
export def prompts(obj)
|
|
485
|
+
result = {}
|
|
486
|
+
for own k, v of obj
|
|
487
|
+
result[k] = v
|
|
488
|
+
result
|
|
489
|
+
|
|
490
|
+
# Handle "Replace ... With ..." editing dance
|
|
491
|
+
export def replace(value)
|
|
492
|
+
mux(
|
|
493
|
+
"Replace ", ["...", " With ", value, " Replace ", ""]
|
|
494
|
+
"// ", value
|
|
495
|
+
ELSE, value
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# Force exact match with double quotes
|
|
499
|
+
export def quote(value)
|
|
500
|
+
"\"#{value}\""
|
|
501
|
+
|
|
502
|
+
# Handle entering a value that may trigger "Are you adding?" confirmation
|
|
503
|
+
export def enter(value, ...rest)
|
|
504
|
+
items = if Array.isArray(value) then value else [value]
|
|
505
|
+
extra = {}
|
|
506
|
+
if rest.length is 1 and typeof rest[0] is 'object' and not Array.isArray(rest[0]) and rest[0] not instanceof RegExp
|
|
507
|
+
extra = rest[0]
|
|
508
|
+
rest = []
|
|
509
|
+
mux(
|
|
510
|
+
"Are you adding", ["Y"]
|
|
511
|
+
...Object.entries(extra).flat()
|
|
512
|
+
...rest
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# ==[ Default Export ]==
|
|
516
|
+
|
|
517
|
+
export default Script
|