@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.
Files changed (3) hide show
  1. package/README.md +395 -0
  2. package/package.json +38 -0
  3. 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