shellfie 1.0.0 → 1.1.0

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -2
  3. data/README.md +258 -97
  4. data/lib/shellfie/animation_frame_builder.rb +31 -7
  5. data/lib/shellfie/animation_timeline.rb +2 -1
  6. data/lib/shellfie/ansi_line_buffer.rb +20 -4
  7. data/lib/shellfie/ansi_normalizer.rb +18 -8
  8. data/lib/shellfie/ansi_parser.rb +120 -7
  9. data/lib/shellfie/cassette.rb +76 -0
  10. data/lib/shellfie/cli.rb +65 -2
  11. data/lib/shellfie/cli_authoring.rb +233 -0
  12. data/lib/shellfie/cli_generate.rb +244 -40
  13. data/lib/shellfie/cli_info.rb +106 -6
  14. data/lib/shellfie/cli_run.rb +167 -0
  15. data/lib/shellfie/config.rb +5 -1
  16. data/lib/shellfie/config_defaults.rb +21 -2
  17. data/lib/shellfie/config_validation.rb +93 -4
  18. data/lib/shellfie/dependency_checker.rb +74 -3
  19. data/lib/shellfie/errors.rb +1 -0
  20. data/lib/shellfie/ffmpeg_encoder.rb +46 -0
  21. data/lib/shellfie/font_resolver.rb +11 -1
  22. data/lib/shellfie/gif_generator.rb +157 -11
  23. data/lib/shellfie/gif_palette.rb +8 -4
  24. data/lib/shellfie/html_renderer.rb +54 -0
  25. data/lib/shellfie/line_layout.rb +19 -10
  26. data/lib/shellfie/output_writer.rb +7 -2
  27. data/lib/shellfie/parser.rb +82 -19
  28. data/lib/shellfie/parser_validation.rb +48 -15
  29. data/lib/shellfie/render_geometry.rb +2 -1
  30. data/lib/shellfie/render_segment.rb +17 -5
  31. data/lib/shellfie/renderer.rb +28 -11
  32. data/lib/shellfie/rendering/text_painter.rb +23 -15
  33. data/lib/shellfie/rendering/window_chrome.rb +2 -2
  34. data/lib/shellfie/reproducibility_manifest.rb +41 -0
  35. data/lib/shellfie/session.rb +111 -0
  36. data/lib/shellfie/session_config.rb +562 -0
  37. data/lib/shellfie/session_runner.rb +689 -0
  38. data/lib/shellfie/svg_renderer.rb +222 -0
  39. data/lib/shellfie/terminal_screen.rb +389 -0
  40. data/lib/shellfie/text_metrics.rb +74 -15
  41. data/lib/shellfie/transcript_renderer.rb +92 -0
  42. data/lib/shellfie/version.rb +1 -1
  43. data/lib/shellfie/yaml_safety.rb +147 -0
  44. data/lib/shellfie.rb +8 -1
  45. data/schema/shellfie-v1.schema.json +153 -0
  46. data/schema/shellfie-v2.schema.json +262 -0
  47. metadata +27 -24
  48. data/.rspec +0 -3
  49. data/Rakefile +0 -8
  50. data/docs/.nojekyll +0 -0
  51. data/docs/index.html +0 -205
  52. data/docs/scripts.js +0 -85
  53. data/docs/styles.css +0 -507
  54. data/examples/animation.yml +0 -33
  55. data/examples/colored.yml +0 -20
  56. data/examples/demo.gif +0 -0
  57. data/examples/demo.png +0 -0
  58. data/examples/demo_animation.yml +0 -31
  59. data/examples/headless.png +0 -0
  60. data/examples/headless.yml +0 -16
  61. data/examples/scrolling.yml +0 -48
  62. data/examples/simple.yml +0 -21
  63. data/examples/theme_macos.png +0 -0
  64. data/examples/theme_ubuntu.png +0 -0
  65. data/examples/theme_windows.png +0 -0
  66. data/shellfie.gemspec +0 -32
@@ -0,0 +1,689 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pty"
4
+ require "io/console"
5
+ require "rbconfig"
6
+ require "open3"
7
+ require "securerandom"
8
+ require "timeout"
9
+ require "fileutils"
10
+ require "tmpdir"
11
+ require "shellwords"
12
+ require_relative "errors"
13
+ require_relative "session"
14
+ require_relative "text_metrics"
15
+
16
+ module Shellfie
17
+ class SessionRunner
18
+ MAX_OUTPUT_BYTES = 10 * 1_048_576
19
+ REGEXP_TIMEOUT = 0.1
20
+ MAX_GOLDEN_BYTES = 1_048_576
21
+ KEYS = {
22
+ "enter" => "\r", "tab" => "\t", "esc" => "\e", "escape" => "\e",
23
+ "up" => "\e[A", "down" => "\e[B", "right" => "\e[C", "left" => "\e[D",
24
+ "home" => "\e[H", "end" => "\e[F", "pageup" => "\e[5~", "pagedown" => "\e[6~",
25
+ "delete" => "\e[3~", "insert" => "\e[2~", "backspace" => "\x7f", "shift-tab" => "\e[Z"
26
+ }.freeze
27
+
28
+ def initialize(config)
29
+ @config = config
30
+ @visible = true
31
+ @redactions = config.redactions.map { |pattern| Regexp.new(pattern.to_s) }
32
+ @utf8_pending = +"".b
33
+ end
34
+
35
+ def run
36
+ total_timeout = terminal[:total_timeout] && parse_duration(terminal[:total_timeout])
37
+ return Timeout.timeout(total_timeout, ExecutionError, "Session exceeded total timeout of #{total_timeout}s") { execute_session } if total_timeout
38
+
39
+ execute_session
40
+ ensure
41
+ stop_pty
42
+ end
43
+
44
+ def execute_session
45
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
46
+ check_requirements!
47
+ validate_working_directories!
48
+ @session = Session.new(columns: terminal[:columns], rows: terminal[:rows], title: @config.title)
49
+ @live_screen = TerminalScreen.new(
50
+ columns: terminal[:columns], rows: terminal[:rows],
51
+ graphics_policy: @config.render.dig(:window, :graphics_policy) || "ignore"
52
+ )
53
+ start_pty
54
+ @config.steps.each do |step|
55
+ execute(step)
56
+ check_reader_error!
57
+ end
58
+ @session
59
+ end
60
+
61
+ private
62
+
63
+ def terminal
64
+ @config.terminal
65
+ end
66
+
67
+ def start_pty
68
+ @prompt_marker = SecureRandom.hex(4)
69
+ env = {
70
+ "TERM" => "xterm-256color", "LANG" => "C.UTF-8", "TZ" => "UTC", "HISTFILE" => "/dev/null",
71
+ "PATH" => ENV.fetch("PATH", ""), "HOME" => (@session_home = Dir.mktmpdir("shellfie-home")), "TMPDIR" => Dir.tmpdir
72
+ }.merge(terminal[:env].transform_keys(&:to_s))
73
+ env["PS1"] = "#{terminal[:prompt]}\e]9;#{@prompt_marker}\a"
74
+ shell = resolve_command(terminal[:shell])
75
+ raise DependencyError, "Shell not found: #{terminal[:shell]}" unless shell
76
+ args = case File.basename(shell)
77
+ when "bash" then %w[--noprofile --norc -i]
78
+ when "zsh" then %w[-f -i]
79
+ when "fish" then %w[--no-config -i]
80
+ when "pwsh", "powershell" then %w[-NoLogo -NoProfile -NoExit]
81
+ when "cmd", "cmd.exe" then %w[/Q /K]
82
+ when "nu" then %w[--no-config-file]
83
+ else %w[-i]
84
+ end
85
+ cwd = File.expand_path(terminal[:cwd], @config.base_dir)
86
+ @buffer = +""
87
+ @mutex = Mutex.new
88
+ @condition = ConditionVariable.new
89
+ @master, @slave, @pid = PTY.spawn(env, shell, *args, chdir: cwd, unsetenv_others: true)
90
+ @master.winsize = [terminal[:rows], terminal[:columns]]
91
+ @reader_thread = Thread.new do
92
+ loop do
93
+ append_terminal_chunk(decode_terminal_bytes(@master.readpartial(4096)))
94
+ break if @reader_error
95
+ end
96
+ rescue EOFError, Errno::EIO, IOError
97
+ nil
98
+ ensure
99
+ append_terminal_chunk(decode_terminal_bytes("", final: true))
100
+ end
101
+ wait_for_quiet(0.1)
102
+ clear_buffer
103
+ @recorded_offset = 0
104
+ @prompt_wait_offset = 0
105
+ rescue SystemCallError => e
106
+ raise DependencyError, "Unable to start shell #{terminal[:shell]}: #{e.message}"
107
+ end
108
+
109
+ def stop_pty
110
+ return unless @pid
111
+
112
+ terminate_job_groups(background_job_pids)
113
+ terminate_descendants
114
+ @slave&.write("exit\n") unless @slave&.closed?
115
+ unless reap_child(1)
116
+ signal_process_group("TERM")
117
+ signal_process(@pid, "TERM")
118
+ unless reap_child(1)
119
+ signal_process_group("KILL")
120
+ signal_process(@pid, "KILL")
121
+ reap_child(1)
122
+ end
123
+ end
124
+ rescue Errno::ESRCH, Errno::ECHILD, Errno::EIO, Errno::EPIPE, IOError
125
+ nil
126
+ ensure
127
+ signal_process_group("TERM")
128
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.2
129
+ sleep(0.01) while process_group_alive? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
130
+ signal_process_group("KILL") if process_group_alive?
131
+ @master&.close unless @master&.closed?
132
+ @slave&.close unless @slave&.closed?
133
+ @reader_thread&.join(0.2)
134
+ FileUtils.rm_rf(@session_home) if @session_home
135
+ end
136
+
137
+ def reap_child(timeout)
138
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
139
+ loop do
140
+ return true if Process.waitpid(@pid, Process::WNOHANG)
141
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
142
+
143
+ sleep(0.01)
144
+ end
145
+ rescue Errno::ECHILD
146
+ true
147
+ end
148
+
149
+ def background_job_pids
150
+ start = buffer_size
151
+ opening = "__SHELLFIE_JOBS_#{SecureRandom.hex(6)}__"
152
+ closing = "__SHELLFIE_JOBS_END_#{SecureRandom.hex(6)}__"
153
+ @slave.write("printf '\n#{opening}\n'; jobs -p; printf '#{closing}\n'\n")
154
+ wait_for(/\r?\n#{Regexp.escape(closing)}\r?\n/, timeout: 0.5, offset: start)
155
+ output = buffer_from(start)
156
+ body = output[/\r?\n#{Regexp.escape(opening)}\r?\n(.*?)\r?\n#{Regexp.escape(closing)}\r?\n/m, 1].to_s
157
+ body.lines.filter_map { |line| Integer(line.strip, exception: false) }
158
+ rescue Shellfie::Error, SystemCallError, IOError
159
+ []
160
+ end
161
+
162
+ def terminate_job_groups(pids)
163
+ pids.each { |pid| signal_group(pid, "TERM") }
164
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.2
165
+ sleep(0.01) while pids.any? { |pid| process_group_exists?(pid) } && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
166
+ pids.each { |pid| signal_group(pid, "KILL") if process_group_exists?(pid) }
167
+ end
168
+
169
+ def terminate_descendants
170
+ pids = descendant_pids
171
+ pids.reverse_each { |pid| signal_process(pid, "TERM") }
172
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.2
173
+ sleep(0.01) while pids.any? { |pid| process_exists?(pid) } && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
174
+ pids.reverse_each { |pid| signal_process(pid, "KILL") if process_exists?(pid) }
175
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
176
+ sleep(0.01) while pids.any? { |pid| process_exists?(pid) } && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
177
+ end
178
+
179
+ def descendant_pids
180
+ output, status = Open3.capture2("ps", "-eo", "pid=,ppid=")
181
+ return [] unless status.success?
182
+
183
+ children = Hash.new { |hash, key| hash[key] = [] }
184
+ output.each_line do |line|
185
+ pid, parent = line.split.map!(&:to_i)
186
+ children[parent] << pid if pid&.positive? && parent&.positive?
187
+ end
188
+ descendants = []
189
+ pending = [@pid]
190
+ until pending.empty?
191
+ found = children[pending.shift]
192
+ descendants.concat(found)
193
+ pending.concat(found)
194
+ end
195
+ descendants
196
+ rescue SystemCallError
197
+ []
198
+ end
199
+
200
+ def signal_process(pid, signal)
201
+ Process.kill(signal, pid)
202
+ rescue Errno::ESRCH, Errno::EPERM
203
+ nil
204
+ end
205
+
206
+ def signal_group(pid, signal)
207
+ Process.kill(signal, -pid)
208
+ rescue Errno::ESRCH, Errno::EPERM
209
+ nil
210
+ end
211
+
212
+ def process_group_exists?(pid)
213
+ Process.kill(0, -pid)
214
+ true
215
+ rescue Errno::ESRCH
216
+ false
217
+ rescue Errno::EPERM
218
+ true
219
+ end
220
+
221
+ def process_exists?(pid)
222
+ Process.kill(0, pid)
223
+ true
224
+ rescue Errno::ESRCH
225
+ false
226
+ rescue Errno::EPERM
227
+ true
228
+ end
229
+
230
+ def execute(step)
231
+ action = (step.keys & SessionConfig::ACTIONS).first
232
+ case action
233
+ when :hide then set_visibility(false)
234
+ when :show then set_visibility(true)
235
+ when :run then execute_command(step[:run], step, visible: step.fetch(:visibility, step[:async] ? "visible" : "hidden") != "hidden")
236
+ when :type then type(step[:type], step)
237
+ when :key then key(step[:key], step)
238
+ when :sleep
239
+ delay = parse_duration(step[:sleep])
240
+ @session.record("", delay: delay, visible: @visible)
241
+ sleep(delay)
242
+ when :wait
243
+ wait_condition(step[:wait], timeout: step_timeout(step))
244
+ flush_pending
245
+ when :expect then expect_condition(step[:expect])
246
+ when :capture
247
+ flush_pending
248
+ @session.capture(step[:capture])
249
+ end
250
+ end
251
+
252
+ def execute_command(command, step, visible:)
253
+ flush_pending
254
+ command = "(cd #{Shellwords.escape(step_directory(step[:cwd]))} && #{command})" if step[:cwd]
255
+ start = buffer_size
256
+ @prompt_wait_offset = start
257
+ @slave.write("#{command}\n")
258
+ if step[:async]
259
+ wait_for_quiet(0.03)
260
+ text, @recorded_offset = buffer_snapshot_from(start)
261
+ event = @session.record(redact(text), delay: 0.03, visible: @visible && visible)
262
+ return event
263
+ end
264
+
265
+ text, status = finish_command(start, timeout: step_timeout(step))
266
+ @recorded_offset = buffer_size
267
+ @session.record(redact(text), delay: 0.1, visible: @visible && visible, status: status)
268
+ end
269
+
270
+ def type(text, step)
271
+ flush_pending
272
+ delay = typing_delay(step[:speed])
273
+ start = buffer_size
274
+ TextMetrics.graphemes(text).each do |grapheme|
275
+ @slave.write(grapheme)
276
+ sleep(delay) if delay.positive?
277
+ end
278
+ wait_for_quiet(0.03)
279
+ typed, @recorded_offset = buffer_snapshot_from(start)
280
+ @session.record(redact(typed), delay: delay * TextMetrics.graphemes(text).size, visible: @visible)
281
+ end
282
+
283
+ def key(name, step)
284
+ sequence = key_sequence(name)
285
+ count = Integer(step[:count] || 1)
286
+ delay = step[:delay] ? parse_duration(step[:delay]) : 0
287
+ flush_pending
288
+ if delay.zero?
289
+ start = buffer_size
290
+ @prompt_wait_offset = start if name.downcase == "enter"
291
+ @slave.write(sequence * count)
292
+ if name.downcase == "enter" && !step[:async]
293
+ text, status = finish_command(start, timeout: step_timeout(step))
294
+ @recorded_offset = buffer_size
295
+ @session.record(redact(text), delay: 0.1, visible: @visible, status: status)
296
+ else
297
+ wait_for_quiet(0.03)
298
+ text, @recorded_offset = buffer_snapshot_from(start)
299
+ @session.record(redact(text), delay: 0.03, visible: @visible)
300
+ end
301
+ return
302
+ end
303
+
304
+ count.times do |index|
305
+ start = buffer_size
306
+ @prompt_wait_offset = start if name.downcase == "enter"
307
+ @slave.write(sequence)
308
+ if name.downcase == "enter" && !step[:async] && index == count - 1
309
+ text, status = finish_command(start, timeout: step_timeout(step))
310
+ @recorded_offset = buffer_size
311
+ @session.record(redact(text), delay: 0.1, visible: @visible, status: status)
312
+ else
313
+ index < count - 1 ? sleep(delay) : wait_for_quiet(0.03)
314
+ text, @recorded_offset = buffer_snapshot_from(start)
315
+ @session.record(redact(text), delay: index < count - 1 ? delay : 0.03, visible: @visible)
316
+ end
317
+ end
318
+ end
319
+
320
+ def finish_command(start, timeout:, marker_prefix: "")
321
+ marker = "__SF_#{SecureRandom.hex(6)}__"
322
+ marker_command = "#{marker_prefix}printf '\\n#{marker}:%s\\n' \"$?\""
323
+ @slave.write("#{marker_command}\n")
324
+ match = wait_for(/#{Regexp.escape(marker)}:(\d+)/, timeout: timeout, offset: start)
325
+ text = buffer_from(start)[0...match.begin(0)]
326
+ text = text.gsub(marker_command, "")
327
+ [text, match[1].to_i]
328
+ end
329
+
330
+ def wait_condition(value, timeout:)
331
+ condition = value.is_a?(Hash) ? value.transform_keys(&:to_sym) : { screen: value }
332
+ return wait_for_exit(timeout: parse_duration(condition[:timeout] || timeout)) if condition[:exit]
333
+ return wait_for_stable(parse_duration(condition[:stable]), timeout: parse_duration(condition[:timeout] || timeout)) if condition[:stable]
334
+ if condition[:prompt]
335
+ marker = Regexp.escape("\e]9;#{@prompt_marker}\a")
336
+ pattern = /#{marker}(?:\e\[[0-9;?]*[ -\/]*[@-~])*\z/
337
+ return wait_for(pattern, timeout: parse_duration(condition[:timeout] || timeout), offset: @prompt_wait_offset, prompt: true)
338
+ end
339
+
340
+ pattern = condition[:screen] || condition[:line]
341
+ raise ValidationError, "wait requires screen or line" unless pattern
342
+
343
+ wait_for(
344
+ Regexp.new(pattern.to_s), timeout: parse_duration(condition[:timeout] || timeout),
345
+ screen: condition.key?(:screen), line: condition.key?(:line)
346
+ )
347
+ rescue RegexpError => e
348
+ raise ValidationError, "Invalid wait pattern: #{e.message}"
349
+ end
350
+
351
+ def wait_for_exit(timeout:)
352
+ start = @recorded_offset
353
+ text, status = finish_command(start, timeout: timeout)
354
+ @recorded_offset = buffer_size
355
+ @session.record(redact(text), delay: 0.1, visible: @visible, status: status)
356
+ end
357
+
358
+ def wait_for_stable(seconds, timeout:)
359
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
360
+ previous = live_screen_text
361
+ stable_since = Process.clock_gettime(Process::CLOCK_MONOTONIC)
362
+ loop do
363
+ check_reader_error!
364
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
365
+ current = live_screen_text
366
+ return if current == previous && now - stable_since >= seconds
367
+ raise ExecutionError, "Timed out after #{timeout}s waiting for a stable screen" if now >= deadline
368
+
369
+ if current != previous
370
+ previous = current
371
+ stable_since = now
372
+ end
373
+ @mutex.synchronize { @condition.wait(@mutex, [[deadline - now, seconds].min, 0.05].min) }
374
+ end
375
+ end
376
+
377
+ def expect_condition(value)
378
+ condition = value.is_a?(Hash) ? value.transform_keys(&:to_sym) : { screen_contains: value }
379
+ current = @session.screen.to_s
380
+ if condition[:screen_contains] && !current.include?(condition[:screen_contains].to_s)
381
+ raise ExecutionError, "Expected screen to contain #{condition[:screen_contains].inspect}"
382
+ end
383
+ if condition[:screen] && !regex_match(Regexp.new(condition[:screen].to_s), current)
384
+ raise ExecutionError, "Expected screen to match #{condition[:screen].inspect}"
385
+ end
386
+ if condition[:line] && !line_match(Regexp.new(condition[:line]), current)
387
+ raise ExecutionError, "Expected a line to match #{condition[:line].inspect}"
388
+ end
389
+ if condition.key?(:exit_status) && @session.exit_status != condition[:exit_status]
390
+ raise ExecutionError, "Expected exit status #{condition[:exit_status]}, got #{@session.exit_status.inspect}"
391
+ end
392
+ if condition[:golden]
393
+ path = File.expand_path(condition[:golden], @config.base_dir)
394
+ expected = File.open(path, "rb") { |file| file.read(MAX_GOLDEN_BYTES + 1) }
395
+ raise ResourceLimitError, "Text golden is too large (max #{MAX_GOLDEN_BYTES} bytes)" if expected.bytesize > MAX_GOLDEN_BYTES
396
+ raise ExecutionError, "Text golden mismatch: #{condition[:golden]}" unless expected == "#{current}\n"
397
+ end
398
+ %i[row column].each do |coordinate|
399
+ expected = condition[:"cursor_#{coordinate}"]
400
+ actual = @session.screen.public_send(coordinate)
401
+ raise ExecutionError, "Expected cursor #{coordinate} #{expected}, got #{actual}" if expected && actual != expected
402
+ end
403
+ if condition[:elapsed_under] || condition[:elapsed_over]
404
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at
405
+ if condition[:elapsed_under] && elapsed > parse_duration(condition[:elapsed_under])
406
+ raise ExecutionError, "Expected elapsed time under #{condition[:elapsed_under]}, got #{elapsed.round(3)}s"
407
+ end
408
+ if condition[:elapsed_over] && elapsed < parse_duration(condition[:elapsed_over])
409
+ raise ExecutionError, "Expected elapsed time over #{condition[:elapsed_over]}, got #{elapsed.round(3)}s"
410
+ end
411
+ end
412
+ rescue RegexpError => e
413
+ raise ValidationError, "Invalid expect pattern: #{e.message}"
414
+ rescue Errno::ENOENT
415
+ raise FileSystemError, "Text golden not found: #{condition[:golden]}"
416
+ end
417
+
418
+ def wait_for(pattern, timeout:, screen: false, line: false, offset: 0, prompt: false)
419
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
420
+ loop do
421
+ check_reader_error!
422
+ text = (screen || line) ? live_screen_text : buffer_from(offset)
423
+ match = if line
424
+ line_match(pattern, text, deadline: deadline)
425
+ else
426
+ regex_match(pattern, text)
427
+ end
428
+ return match if match && (!prompt || foreground_shell?)
429
+
430
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
431
+ raise ExecutionError, "Timed out after #{timeout}s waiting for #{pattern.inspect}" unless remaining.positive?
432
+
433
+ @mutex.synchronize { @condition.wait(@mutex, [remaining, 0.05].min) }
434
+ end
435
+ end
436
+
437
+ def wait_for_quiet(seconds)
438
+ previous = -1
439
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + parse_duration(terminal[:timeout])
440
+ loop do
441
+ check_reader_error!
442
+ current = buffer_size
443
+ return if current == previous
444
+ raise ExecutionError, "Terminal did not become idle" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
445
+
446
+ previous = current
447
+ sleep(seconds)
448
+ end
449
+ end
450
+
451
+ def check_requirements!
452
+ missing = @config.requires.reject { |command| resolve_command(command) }
453
+ raise DependencyError, "Required command(s) not found: #{missing.join(", ")}" unless missing.empty?
454
+ end
455
+
456
+ def validate_working_directories!
457
+ paths = [File.expand_path(terminal[:cwd], @config.base_dir)]
458
+ paths.concat(@config.steps.filter_map { |step| step_directory(step[:cwd]) if step[:cwd] })
459
+ missing = paths.find { |path| !File.directory?(path) }
460
+ raise FileSystemError, "Working directory not found: #{missing}" if missing
461
+ return unless terminal[:cwd_policy] == "root"
462
+
463
+ root = File.realpath(@config.base_dir)
464
+ escaped = paths.map { |path| File.realpath(path) }
465
+ .find { |path| path != root && !path.start_with?("#{root}#{File::SEPARATOR}") }
466
+ raise FileSystemError, "Working directory escapes the session root: #{escaped}" if escaped
467
+ end
468
+
469
+ def step_directory(path)
470
+ File.expand_path(path, File.expand_path(terminal[:cwd], @config.base_dir))
471
+ end
472
+
473
+ def decode_terminal_bytes(chunk, final: false)
474
+ bytes = @utf8_pending << chunk.b
475
+ @utf8_pending = final ? +"".b : incomplete_utf8_suffix(bytes)
476
+ complete_size = bytes.bytesize - @utf8_pending.bytesize
477
+ bytes.byteslice(0, complete_size).to_s.force_encoding(Encoding::UTF_8).scrub
478
+ end
479
+
480
+ def incomplete_utf8_suffix(bytes)
481
+ (bytes.bytesize - 1).downto([bytes.bytesize - 3, 0].max) do |index|
482
+ byte = bytes.getbyte(index)
483
+ next if byte.between?(0x80, 0xbf)
484
+
485
+ expected = if byte.between?(0xc2, 0xdf)
486
+ 2
487
+ elsif byte.between?(0xe0, 0xef)
488
+ 3
489
+ elsif byte.between?(0xf0, 0xf4)
490
+ 4
491
+ end
492
+ suffix = bytes.byteslice(index..)
493
+ return suffix if expected && suffix.bytesize < expected && suffix.bytes.drop(1).all? { |part| part.between?(0x80, 0xbf) }
494
+
495
+ break
496
+ end
497
+ +"".b
498
+ end
499
+
500
+ def append_terminal_chunk(chunk)
501
+ return if chunk.empty?
502
+
503
+ @mutex.synchronize do
504
+ if @buffer.bytesize + chunk.bytesize > MAX_OUTPUT_BYTES
505
+ @reader_error = ResourceLimitError.new("Terminal output exceeded #{MAX_OUTPUT_BYTES} bytes")
506
+ else
507
+ @buffer << chunk
508
+ begin
509
+ @live_screen.feed(chunk)
510
+ rescue Shellfie::Error => e
511
+ @reader_error = e
512
+ end
513
+ end
514
+ @condition.broadcast
515
+ end
516
+ end
517
+
518
+ def resolve_command(command)
519
+ if command.include?(File::SEPARATOR)
520
+ path = File.expand_path(command, @config.base_dir)
521
+ return path if File.executable?(path)
522
+ end
523
+
524
+ env = terminal[:env]
525
+ path = if env.key?("PATH")
526
+ env["PATH"]
527
+ elsif env.key?(:PATH)
528
+ env[:PATH]
529
+ else
530
+ ENV.fetch("PATH", "")
531
+ end
532
+ path.to_s.split(File::PATH_SEPARATOR)
533
+ .map { |path| File.join(path, command) }
534
+ .find { |candidate| File.file?(candidate) && File.executable?(candidate) }
535
+ end
536
+
537
+ def key_sequence(name)
538
+ normalized = name.downcase
539
+ return KEYS.fetch(normalized) if KEYS.key?(normalized)
540
+
541
+ parts = normalized.split("-")
542
+ base = parts.pop
543
+ modifiers = parts
544
+ raise ValidationError, "Unsupported key: #{name}" unless (modifiers - %w[shift alt ctrl meta]).empty?
545
+
546
+ if base.length == 1
547
+ key = modifiers.include?("shift") ? base.upcase : base
548
+ key = (key.ord & 0x1f).chr if modifiers.include?("ctrl")
549
+ key = "\e#{key}" if (modifiers & %w[alt meta]).any?
550
+ return key
551
+ end
552
+
553
+ bits = 0
554
+ bits |= 1 if modifiers.include?("shift")
555
+ bits |= 2 if modifiers.include?("alt")
556
+ bits |= 4 if modifiers.include?("ctrl")
557
+ bits |= 8 if modifiers.include?("meta")
558
+ parameter = bits + 1
559
+ suffix = { "up" => "A", "down" => "B", "right" => "C", "left" => "D", "home" => "H", "end" => "F" }[base]
560
+ return "\e[1;#{parameter}#{suffix}" if suffix && bits.positive?
561
+
562
+ tilde = { "insert" => 2, "delete" => 3, "pageup" => 5, "pagedown" => 6 }[base]
563
+ return "\e[#{tilde};#{parameter}~" if tilde && bits.positive?
564
+
565
+ raise ValidationError, "Unsupported key: #{name}"
566
+ end
567
+
568
+ def typing_delay(value)
569
+ rate = value.to_s[/\d+(?:\.\d+)?/]&.to_f || 30.0
570
+ rate = 30.0 unless rate.positive?
571
+ 1.0 / rate
572
+ end
573
+
574
+ def step_timeout(step)
575
+ parse_duration(step[:timeout] || terminal[:timeout])
576
+ end
577
+
578
+ def parse_duration(value)
579
+ match = /\A(\d+(?:\.\d+)?)(ms|s)?\z/.match(value.to_s)
580
+ raise ValidationError, "Invalid duration: #{value}" unless match
581
+ raise ValidationError, "Duration is too large" if match[1].bytesize > 32
582
+
583
+ match[2] == "ms" ? match[1].to_f / 1_000 : match[1].to_f
584
+ end
585
+
586
+ def redact(text)
587
+ clean = @prompt_marker ? text.to_s.gsub("\e]9;#{@prompt_marker}\a", "") : text.to_s
588
+ clean = clean.gsub(@session_home, "~") if @session_home
589
+ @redactions.reduce(clean) do |result, pattern|
590
+ with_regexp_timeout { result.gsub(pattern, "[REDACTED]") }
591
+ end
592
+ end
593
+
594
+ def regex_match(pattern, text, timeout: REGEXP_TIMEOUT)
595
+ with_regexp_timeout(timeout) { pattern.match(text) }
596
+ end
597
+
598
+ def line_match(pattern, text, deadline: nil)
599
+ text.each_line do |line|
600
+ remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
601
+ return nil if remaining && !remaining.positive?
602
+
603
+ match = regex_match(pattern, line.chomp, timeout: remaining ? [remaining, REGEXP_TIMEOUT].min : REGEXP_TIMEOUT)
604
+ return match if match
605
+ end
606
+ nil
607
+ end
608
+
609
+ def with_regexp_timeout(seconds = REGEXP_TIMEOUT, &block)
610
+ Timeout.timeout(seconds, &block)
611
+ rescue Timeout::Error
612
+ raise ExecutionError, "Regular expression exceeded #{seconds.round(3)}s"
613
+ end
614
+
615
+ def live_screen_text
616
+ @mutex.synchronize { redact(@live_screen.to_s) }
617
+ end
618
+
619
+ def flush_pending
620
+ text, finish = buffer_snapshot_from(@recorded_offset)
621
+ return if finish <= @recorded_offset
622
+
623
+ @session.record(redact(text), delay: 0.03, visible: @visible)
624
+ @recorded_offset = finish
625
+ end
626
+
627
+ def set_visibility(visible)
628
+ flush_pending
629
+ @visible = visible
630
+ end
631
+
632
+ def clear_buffer
633
+ @mutex.synchronize do
634
+ @buffer.clear
635
+ @live_screen = TerminalScreen.new(
636
+ columns: terminal[:columns], rows: terminal[:rows],
637
+ graphics_policy: @config.render.dig(:window, :graphics_policy) || "ignore"
638
+ )
639
+ end
640
+ end
641
+
642
+ def buffer_size
643
+ @mutex.synchronize { @buffer.bytesize }
644
+ end
645
+
646
+ def buffer_snapshot
647
+ @mutex.synchronize { @buffer.dup }
648
+ end
649
+
650
+ def buffer_from(index)
651
+ @mutex.synchronize { @buffer.byteslice(index..) || "" }
652
+ end
653
+
654
+ def buffer_snapshot_from(index)
655
+ @mutex.synchronize { [@buffer.byteslice(index..) || "", @buffer.bytesize] }
656
+ end
657
+
658
+ def check_reader_error!
659
+ error = @mutex&.synchronize { @reader_error }
660
+ raise error if error
661
+ end
662
+
663
+ def signal_process_group(signal)
664
+ Process.kill(signal, -@pid) if @pid
665
+ rescue Errno::ESRCH, Errno::EPERM
666
+ nil
667
+ end
668
+
669
+ def process_group_alive?
670
+ return false unless @pid
671
+
672
+ Process.kill(0, -@pid)
673
+ true
674
+ rescue Errno::ESRCH
675
+ false
676
+ rescue Errno::EPERM
677
+ true
678
+ end
679
+
680
+ def foreground_shell?
681
+ request = RbConfig::CONFIG["host_os"].include?("darwin") ? 0x40047477 : 0x540f
682
+ process_group = [0].pack("i")
683
+ @master.ioctl(request, process_group)
684
+ process_group.unpack1("i") == @pid
685
+ rescue SystemCallError, IOError, NotImplementedError
686
+ false
687
+ end
688
+ end
689
+ end