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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -2
- data/README.md +258 -97
- data/lib/shellfie/animation_frame_builder.rb +31 -7
- data/lib/shellfie/animation_timeline.rb +2 -1
- data/lib/shellfie/ansi_line_buffer.rb +20 -4
- data/lib/shellfie/ansi_normalizer.rb +18 -8
- data/lib/shellfie/ansi_parser.rb +120 -7
- data/lib/shellfie/cassette.rb +76 -0
- data/lib/shellfie/cli.rb +65 -2
- data/lib/shellfie/cli_authoring.rb +233 -0
- data/lib/shellfie/cli_generate.rb +244 -40
- data/lib/shellfie/cli_info.rb +106 -6
- data/lib/shellfie/cli_run.rb +167 -0
- data/lib/shellfie/config.rb +5 -1
- data/lib/shellfie/config_defaults.rb +21 -2
- data/lib/shellfie/config_validation.rb +93 -4
- data/lib/shellfie/dependency_checker.rb +74 -3
- data/lib/shellfie/errors.rb +1 -0
- data/lib/shellfie/ffmpeg_encoder.rb +46 -0
- data/lib/shellfie/font_resolver.rb +11 -1
- data/lib/shellfie/gif_generator.rb +157 -11
- data/lib/shellfie/gif_palette.rb +8 -4
- data/lib/shellfie/html_renderer.rb +54 -0
- data/lib/shellfie/line_layout.rb +19 -10
- data/lib/shellfie/output_writer.rb +7 -2
- data/lib/shellfie/parser.rb +82 -19
- data/lib/shellfie/parser_validation.rb +48 -15
- data/lib/shellfie/render_geometry.rb +2 -1
- data/lib/shellfie/render_segment.rb +17 -5
- data/lib/shellfie/renderer.rb +28 -11
- data/lib/shellfie/rendering/text_painter.rb +23 -15
- data/lib/shellfie/rendering/window_chrome.rb +2 -2
- data/lib/shellfie/reproducibility_manifest.rb +41 -0
- data/lib/shellfie/session.rb +111 -0
- data/lib/shellfie/session_config.rb +562 -0
- data/lib/shellfie/session_runner.rb +689 -0
- data/lib/shellfie/svg_renderer.rb +222 -0
- data/lib/shellfie/terminal_screen.rb +389 -0
- data/lib/shellfie/text_metrics.rb +74 -15
- data/lib/shellfie/transcript_renderer.rb +92 -0
- data/lib/shellfie/version.rb +1 -1
- data/lib/shellfie/yaml_safety.rb +147 -0
- data/lib/shellfie.rb +8 -1
- data/schema/shellfie-v1.schema.json +153 -0
- data/schema/shellfie-v2.schema.json +262 -0
- metadata +27 -24
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/docs/.nojekyll +0 -0
- data/docs/index.html +0 -205
- data/docs/scripts.js +0 -85
- data/docs/styles.css +0 -507
- data/examples/animation.yml +0 -33
- data/examples/colored.yml +0 -20
- data/examples/demo.gif +0 -0
- data/examples/demo.png +0 -0
- data/examples/demo_animation.yml +0 -31
- data/examples/headless.png +0 -0
- data/examples/headless.yml +0 -16
- data/examples/scrolling.yml +0 -48
- data/examples/simple.yml +0 -21
- data/examples/theme_macos.png +0 -0
- data/examples/theme_ubuntu.png +0 -0
- data/examples/theme_windows.png +0 -0
- data/shellfie.gemspec +0 -32
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "text_metrics"
|
|
4
|
+
|
|
3
5
|
module Shellfie
|
|
4
6
|
class AnsiLineBuffer
|
|
5
|
-
|
|
7
|
+
CONTINUATION = Object.new.freeze
|
|
8
|
+
|
|
9
|
+
def initialize(tab_width: 8)
|
|
6
10
|
@cells = []
|
|
7
11
|
@column = 0
|
|
8
12
|
@pending_escape = +""
|
|
13
|
+
@tab_width = tab_width
|
|
9
14
|
end
|
|
10
15
|
|
|
11
16
|
def write_escape(sequence)
|
|
@@ -18,13 +23,19 @@ module Shellfie
|
|
|
18
23
|
@column = 0
|
|
19
24
|
when "\b"
|
|
20
25
|
@column = [@column - 1, 0].max
|
|
21
|
-
|
|
26
|
+
when "\t"
|
|
27
|
+
@column += @tab_width - (@column % @tab_width)
|
|
22
28
|
when "\a"
|
|
23
29
|
nil
|
|
24
30
|
else
|
|
31
|
+
width = TextMetrics.grapheme_width(char)
|
|
32
|
+
return if width.zero?
|
|
33
|
+
|
|
34
|
+
clear_wide_cell(@column)
|
|
25
35
|
@cells[@column] = "#{@pending_escape}#{char}"
|
|
36
|
+
@cells[@column + 1] = CONTINUATION if width == 2
|
|
26
37
|
@pending_escape = +""
|
|
27
|
-
@column +=
|
|
38
|
+
@column += width
|
|
28
39
|
end
|
|
29
40
|
end
|
|
30
41
|
|
|
@@ -55,7 +66,7 @@ module Shellfie
|
|
|
55
66
|
last = @cells.rindex { |cell| !cell.nil? }
|
|
56
67
|
return @pending_escape unless last
|
|
57
68
|
|
|
58
|
-
@cells[0..last].map { |cell| cell || " " }.join + @pending_escape
|
|
69
|
+
@cells[0..last].map { |cell| cell.equal?(CONTINUATION) ? "" : (cell || " ") }.join + @pending_escape
|
|
59
70
|
end
|
|
60
71
|
|
|
61
72
|
private
|
|
@@ -83,5 +94,10 @@ module Shellfie
|
|
|
83
94
|
def clear_range(range)
|
|
84
95
|
range.each { |index| @cells[index] = nil if index < @cells.length }
|
|
85
96
|
end
|
|
97
|
+
|
|
98
|
+
def clear_wide_cell(column)
|
|
99
|
+
@cells[column - 1] = nil if column.positive? && @cells[column].equal?(CONTINUATION)
|
|
100
|
+
@cells[column + 1] = nil if @cells[column + 1].equal?(CONTINUATION)
|
|
101
|
+
end
|
|
86
102
|
end
|
|
87
103
|
end
|
|
@@ -5,20 +5,25 @@ require_relative "ansi_line_buffer"
|
|
|
5
5
|
|
|
6
6
|
module Shellfie
|
|
7
7
|
module AnsiNormalizer
|
|
8
|
-
ANSI_REGEX = /\e\[([0-9
|
|
8
|
+
ANSI_REGEX = /\e\[([0-9;:]*)m/
|
|
9
9
|
OSC_REGEX = /\e\].*?(?:\a|\e\\)/
|
|
10
|
-
|
|
10
|
+
STRING_CONTROL_REGEX = /\e(?:P|_|\^).*?(?:\a|\e\\)/m
|
|
11
|
+
INCOMPLETE_STRING_CONTROL_REGEX = /\e(?:P|_|\^).*\z/m
|
|
12
|
+
GRAPHICS_CONTROL_PREFIX = /\e(?:_G|P[0-9;?]*q|\]1337;File=)/
|
|
13
|
+
CSI_CONTROL_REGEX = /\e\[[0-9;:?]*[A-Za-ln-z]/
|
|
11
14
|
|
|
12
15
|
module_function
|
|
13
16
|
|
|
14
|
-
def normalize(text)
|
|
15
|
-
text = text.gsub(
|
|
16
|
-
text =
|
|
17
|
+
def normalize(text, tab_width: 8, osc_policy: "ignore")
|
|
18
|
+
text = text.gsub(STRING_CONTROL_REGEX, "").gsub(INCOMPLETE_STRING_CONTROL_REGEX, "")
|
|
19
|
+
text = TextMetrics.graphemes(text).join
|
|
20
|
+
text = text.gsub(OSC_REGEX) { |sequence| osc_policy == "ignore" || !sequence.start_with?("\e]8;") ? "" : sequence }
|
|
21
|
+
text = apply_line_controls(text, tab_width: tab_width)
|
|
17
22
|
text.gsub(CSI_CONTROL_REGEX, "")
|
|
18
23
|
end
|
|
19
24
|
|
|
20
|
-
def apply_line_controls(text)
|
|
21
|
-
buffer = AnsiLineBuffer.new
|
|
25
|
+
def apply_line_controls(text, tab_width: 8)
|
|
26
|
+
buffer = AnsiLineBuffer.new(tab_width: tab_width)
|
|
22
27
|
scanner = StringScanner.new(text)
|
|
23
28
|
|
|
24
29
|
until scanner.eos?
|
|
@@ -27,6 +32,11 @@ module Shellfie
|
|
|
27
32
|
next
|
|
28
33
|
end
|
|
29
34
|
|
|
35
|
+
if scanner.scan(OSC_REGEX)
|
|
36
|
+
buffer.write_escape(scanner.matched)
|
|
37
|
+
next
|
|
38
|
+
end
|
|
39
|
+
|
|
30
40
|
if scanner.scan(/\e\[([0-9;?]*)[JK]/)
|
|
31
41
|
buffer.clear(scanner.matched[-1], scanner[1])
|
|
32
42
|
next
|
|
@@ -42,7 +52,7 @@ module Shellfie
|
|
|
42
52
|
next
|
|
43
53
|
end
|
|
44
54
|
|
|
45
|
-
buffer.write_character(scanner.
|
|
55
|
+
buffer.write_character(scanner.scan(/\X/))
|
|
46
56
|
end
|
|
47
57
|
|
|
48
58
|
buffer.to_s
|
data/lib/shellfie/ansi_parser.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "strscan"
|
|
4
|
+
require "uri"
|
|
4
5
|
require_relative "ansi_colors"
|
|
5
6
|
require_relative "ansi_normalizer"
|
|
7
|
+
require_relative "errors"
|
|
6
8
|
|
|
7
9
|
module Shellfie
|
|
8
10
|
Segment = Struct.new(
|
|
@@ -12,30 +14,57 @@ module Shellfie
|
|
|
12
14
|
:bold,
|
|
13
15
|
:italic,
|
|
14
16
|
:underline,
|
|
17
|
+
:underline_style,
|
|
18
|
+
:underline_color,
|
|
15
19
|
:dim,
|
|
16
20
|
:reverse,
|
|
17
21
|
:strikethrough,
|
|
18
22
|
:overline,
|
|
23
|
+
:blink,
|
|
24
|
+
:conceal,
|
|
25
|
+
:link,
|
|
19
26
|
keyword_init: true
|
|
20
27
|
)
|
|
21
28
|
|
|
22
29
|
class AnsiParser
|
|
23
|
-
ANSI_REGEX = /\e\[([0-9
|
|
30
|
+
ANSI_REGEX = /\e\[([0-9;:]*)m/
|
|
24
31
|
|
|
25
|
-
|
|
32
|
+
OSC_REGEX = /\e\](.*?)?(?:\a|\e\\)/m
|
|
33
|
+
MAX_LINK_BYTES = 2_048
|
|
34
|
+
MAX_OSC_BYTES = MAX_LINK_BYTES + 64
|
|
35
|
+
LINK_SCHEMES = %w[http https mailto].freeze
|
|
36
|
+
|
|
37
|
+
def initialize(state_mode: :persistent, tab_width: 8, osc_policy: "ignore", graphics_policy: "ignore")
|
|
26
38
|
@state_mode = state_mode.to_sym
|
|
39
|
+
@tab_width = tab_width
|
|
40
|
+
@osc_policy = osc_policy.to_s
|
|
41
|
+
@graphics_policy = graphics_policy.to_s
|
|
42
|
+
@link = nil
|
|
43
|
+
@pending_osc = +""
|
|
27
44
|
reset_state
|
|
28
45
|
end
|
|
29
46
|
|
|
30
47
|
def parse(text)
|
|
31
|
-
|
|
48
|
+
if @state_mode == :line
|
|
49
|
+
reset_state
|
|
50
|
+
@link = nil
|
|
51
|
+
end
|
|
32
52
|
|
|
53
|
+
input = @pending_osc + text.to_s
|
|
54
|
+
reject_terminal_graphics!(input)
|
|
55
|
+
input, @pending_osc = split_incomplete_osc(input)
|
|
33
56
|
segments = []
|
|
34
|
-
scanner = StringScanner.new(AnsiNormalizer.normalize(
|
|
57
|
+
scanner = StringScanner.new(AnsiNormalizer.normalize(input, tab_width: @tab_width, osc_policy: @osc_policy))
|
|
35
58
|
current_text = +""
|
|
36
59
|
|
|
37
60
|
until scanner.eos?
|
|
38
|
-
if scanner.scan(
|
|
61
|
+
if scanner.scan(OSC_REGEX)
|
|
62
|
+
unless current_text.empty?
|
|
63
|
+
segments << create_segment(current_text)
|
|
64
|
+
current_text = +""
|
|
65
|
+
end
|
|
66
|
+
process_osc(scanner[1])
|
|
67
|
+
elsif scanner.scan(ANSI_REGEX)
|
|
39
68
|
unless current_text.empty?
|
|
40
69
|
segments << create_segment(current_text)
|
|
41
70
|
current_text = +""
|
|
@@ -52,16 +81,26 @@ module Shellfie
|
|
|
52
81
|
|
|
53
82
|
private
|
|
54
83
|
|
|
84
|
+
def reject_terminal_graphics!(text)
|
|
85
|
+
return unless @graphics_policy == "error" && text.match?(AnsiNormalizer::GRAPHICS_CONTROL_PREFIX)
|
|
86
|
+
|
|
87
|
+
raise ValidationError, "Terminal graphics are not supported; use window.graphics_policy: ignore to discard them"
|
|
88
|
+
end
|
|
89
|
+
|
|
55
90
|
def reset_state
|
|
56
91
|
@foreground = nil
|
|
57
92
|
@background = nil
|
|
58
93
|
@bold = false
|
|
59
94
|
@italic = false
|
|
60
95
|
@underline = false
|
|
96
|
+
@underline_style = nil
|
|
97
|
+
@underline_color = nil
|
|
61
98
|
@dim = false
|
|
62
99
|
@reverse = false
|
|
63
100
|
@strikethrough = false
|
|
64
101
|
@overline = false
|
|
102
|
+
@blink = false
|
|
103
|
+
@conceal = false
|
|
65
104
|
end
|
|
66
105
|
|
|
67
106
|
def create_segment(text)
|
|
@@ -72,22 +111,34 @@ module Shellfie
|
|
|
72
111
|
bold: @bold,
|
|
73
112
|
italic: @italic,
|
|
74
113
|
underline: @underline,
|
|
114
|
+
underline_style: @underline_style,
|
|
115
|
+
underline_color: @underline_color,
|
|
75
116
|
dim: @dim,
|
|
76
117
|
reverse: @reverse,
|
|
77
118
|
strikethrough: @strikethrough,
|
|
78
|
-
overline: @overline
|
|
119
|
+
overline: @overline,
|
|
120
|
+
blink: @blink,
|
|
121
|
+
conceal: @conceal,
|
|
122
|
+
link: @link
|
|
79
123
|
)
|
|
80
124
|
end
|
|
81
125
|
|
|
82
126
|
def process_codes(codes_str)
|
|
83
127
|
return reset_state if codes_str.empty?
|
|
84
128
|
|
|
85
|
-
codes = codes_str
|
|
129
|
+
codes = sgr_codes(codes_str)
|
|
86
130
|
i = 0
|
|
87
131
|
|
|
88
132
|
while i < codes.length
|
|
89
133
|
code = codes[i]
|
|
90
134
|
|
|
135
|
+
if code.is_a?(Array) && code.first == :underline_style
|
|
136
|
+
@underline_style = %i[none single double curly dotted dashed][code.last]
|
|
137
|
+
@underline = @underline_style && @underline_style != :none
|
|
138
|
+
i += 1
|
|
139
|
+
next
|
|
140
|
+
end
|
|
141
|
+
|
|
91
142
|
case code
|
|
92
143
|
when 0
|
|
93
144
|
reset_state
|
|
@@ -99,6 +150,11 @@ module Shellfie
|
|
|
99
150
|
@italic = true
|
|
100
151
|
when 4
|
|
101
152
|
@underline = true
|
|
153
|
+
@underline_style = :single
|
|
154
|
+
when 5, 6
|
|
155
|
+
@blink = true
|
|
156
|
+
when 8
|
|
157
|
+
@conceal = true
|
|
102
158
|
when 7
|
|
103
159
|
@reverse = true
|
|
104
160
|
when 9
|
|
@@ -110,8 +166,13 @@ module Shellfie
|
|
|
110
166
|
@italic = false
|
|
111
167
|
when 24
|
|
112
168
|
@underline = false
|
|
169
|
+
@underline_style = nil
|
|
170
|
+
when 25
|
|
171
|
+
@blink = false
|
|
113
172
|
when 27
|
|
114
173
|
@reverse = false
|
|
174
|
+
when 28
|
|
175
|
+
@conceal = false
|
|
115
176
|
when 29
|
|
116
177
|
@strikethrough = false
|
|
117
178
|
when 30..37, 90..97
|
|
@@ -126,15 +187,67 @@ module Shellfie
|
|
|
126
187
|
i, @background = AnsiColors.parse_extended_color(codes, i)
|
|
127
188
|
when 49
|
|
128
189
|
@background = nil
|
|
190
|
+
when 21
|
|
191
|
+
@underline = true
|
|
192
|
+
@underline_style = :double
|
|
129
193
|
when 53
|
|
130
194
|
@overline = true
|
|
131
195
|
when 55
|
|
132
196
|
@overline = false
|
|
197
|
+
when 58
|
|
198
|
+
i, @underline_color = AnsiColors.parse_extended_color(codes, i)
|
|
199
|
+
when 59
|
|
200
|
+
@underline_color = nil
|
|
133
201
|
end
|
|
134
202
|
|
|
135
203
|
i += 1
|
|
136
204
|
end
|
|
137
205
|
end
|
|
138
206
|
|
|
207
|
+
def sgr_codes(value)
|
|
208
|
+
value.split(";").flat_map do |field|
|
|
209
|
+
parts = field.split(":", -1)
|
|
210
|
+
next(field.empty? ? 0 : field.to_i) if parts.size == 1
|
|
211
|
+
|
|
212
|
+
code, mode = parts.values_at(0, 1).map(&:to_i)
|
|
213
|
+
if code == 4 && parts.size == 2 && parts.last.match?(/\A[0-5]\z/)
|
|
214
|
+
[[:underline_style, parts.last.to_i]]
|
|
215
|
+
elsif [38, 48, 58].include?(code) && mode == 2 && [5, 6].include?(parts.size) && parts.last(3).all? { |item| item.match?(/\A\d+\z/) }
|
|
216
|
+
[code, mode, *parts.last(3).map(&:to_i)]
|
|
217
|
+
elsif [38, 48, 58].include?(code) && mode == 5 && parts.size == 3 && parts.last.match?(/\A\d+\z/)
|
|
218
|
+
[code, mode, parts.last.to_i]
|
|
219
|
+
else
|
|
220
|
+
[]
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def process_osc(value)
|
|
226
|
+
return unless value.to_s.start_with?("8;")
|
|
227
|
+
|
|
228
|
+
uri = value.to_s.split(";", 3)[2].to_s
|
|
229
|
+
@link = uri.empty? ? nil : safe_link(uri)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def safe_link(value)
|
|
233
|
+
return if value.bytesize > MAX_LINK_BYTES || value.match?(/[\x00-\x1f\x7f]/)
|
|
234
|
+
|
|
235
|
+
uri = URI.parse(value)
|
|
236
|
+
value if LINK_SCHEMES.include?(uri.scheme&.downcase)
|
|
237
|
+
rescue URI::InvalidURIError
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def split_incomplete_osc(value)
|
|
242
|
+
start = value.rindex("\e]")
|
|
243
|
+
return [value, +""] unless start
|
|
244
|
+
|
|
245
|
+
tail = value[start..]
|
|
246
|
+
return [value, +""] if tail.match?(OSC_REGEX)
|
|
247
|
+
return [value[0...start], tail] if tail.bytesize <= MAX_OSC_BYTES
|
|
248
|
+
|
|
249
|
+
[value[0...start], +""]
|
|
250
|
+
end
|
|
251
|
+
|
|
139
252
|
end
|
|
140
253
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "output_writer"
|
|
6
|
+
require_relative "session"
|
|
7
|
+
|
|
8
|
+
module Shellfie
|
|
9
|
+
class Cassette
|
|
10
|
+
MAX_BYTES = 32 * 1_048_576
|
|
11
|
+
MAX_EVENTS = 10_000
|
|
12
|
+
TOP_LEVEL_KEYS = %i[version title columns rows events captures exit_status].freeze
|
|
13
|
+
EVENT_KEYS = %i[text delay visible status screen].freeze
|
|
14
|
+
|
|
15
|
+
def self.write(path, session)
|
|
16
|
+
OutputWriter.write(path, extension: "json") do |temporary_path|
|
|
17
|
+
json = JSON.pretty_generate(session.to_h)
|
|
18
|
+
raise ResourceLimitError, "Cassette is too large (max #{MAX_BYTES} bytes)" if json.bytesize > MAX_BYTES
|
|
19
|
+
|
|
20
|
+
File.write(temporary_path, json)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.read(path)
|
|
25
|
+
raise ParseError, "Cassette file not found: #{path}" unless File.file?(path)
|
|
26
|
+
content = File.open(path, "rb") { |file| file.read(MAX_BYTES + 1) }
|
|
27
|
+
raise ParseError, "Cassette is too large (max #{MAX_BYTES} bytes)" if content.bytesize > MAX_BYTES
|
|
28
|
+
|
|
29
|
+
raw = JSON.parse(content, symbolize_names: true)
|
|
30
|
+
validate!(raw)
|
|
31
|
+
|
|
32
|
+
Session.new(
|
|
33
|
+
columns: raw[:columns],
|
|
34
|
+
rows: raw[:rows],
|
|
35
|
+
title: raw[:title],
|
|
36
|
+
events: raw[:events] || [],
|
|
37
|
+
captures: raw[:captures] || {},
|
|
38
|
+
exit_status: raw[:exit_status]
|
|
39
|
+
)
|
|
40
|
+
rescue JSON::ParserError => e
|
|
41
|
+
raise ParseError, "Invalid cassette: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.validate!(raw)
|
|
45
|
+
raise ParseError, "Cassette must be a JSON object" unless raw.is_a?(Hash)
|
|
46
|
+
raise ParseError, "Unsupported cassette version" unless raw[:version] == 1
|
|
47
|
+
raise ParseError, "Unknown cassette key" unless (raw.keys - TOP_LEVEL_KEYS).empty?
|
|
48
|
+
raise ParseError, "Cassette title must be a string" unless raw[:title].is_a?(String)
|
|
49
|
+
unless raw[:columns].is_a?(Integer) && raw[:columns].between?(1, 500) &&
|
|
50
|
+
raw[:rows].is_a?(Integer) && raw[:rows].between?(1, 200)
|
|
51
|
+
raise ParseError, "Invalid cassette dimensions"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
events = raw[:events] || []
|
|
55
|
+
raise ParseError, "Cassette events must be an array" unless events.is_a?(Array)
|
|
56
|
+
raise ParseError, "Cassette has too many events" if events.size > MAX_EVENTS
|
|
57
|
+
events.each_with_index do |event, index|
|
|
58
|
+
unless event.is_a?(Hash) && (event.keys - EVENT_KEYS).empty? && event[:text].is_a?(String) &&
|
|
59
|
+
event[:delay].is_a?(Numeric) && event[:delay].finite? && event[:delay] >= 0 &&
|
|
60
|
+
[true, false].include?(event.fetch(:visible, true)) &&
|
|
61
|
+
(!event.key?(:screen) || event[:screen].is_a?(Array) && event[:screen].all?(String)) &&
|
|
62
|
+
(event[:status].nil? || event[:status].is_a?(Integer) && event[:status].between?(0, 255))
|
|
63
|
+
raise ParseError, "Invalid cassette event at index #{index}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
captures = raw[:captures] || {}
|
|
68
|
+
unless captures.is_a?(Hash) && captures.all? { |name, lines| name.is_a?(Symbol) && lines.is_a?(Array) && lines.all?(String) }
|
|
69
|
+
raise ParseError, "Cassette captures must map names to arrays of strings"
|
|
70
|
+
end
|
|
71
|
+
unless raw[:exit_status].nil? || raw[:exit_status].is_a?(Integer) && raw[:exit_status].between?(0, 255)
|
|
72
|
+
raise ParseError, "Invalid cassette exit status"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/shellfie/cli.rb
CHANGED
|
@@ -4,14 +4,19 @@ require "optparse"
|
|
|
4
4
|
require_relative "../shellfie"
|
|
5
5
|
require_relative "cli_generate"
|
|
6
6
|
require_relative "cli_info"
|
|
7
|
+
require_relative "cli_run"
|
|
8
|
+
require_relative "cli_authoring"
|
|
7
9
|
require_relative "dependency_checker"
|
|
10
|
+
require_relative "transcript_renderer"
|
|
8
11
|
|
|
9
12
|
module Shellfie
|
|
10
13
|
class CLI
|
|
11
14
|
include CLIGenerate
|
|
12
15
|
include CLIInfo
|
|
16
|
+
include CLIRun
|
|
17
|
+
include CLIAuthoring
|
|
13
18
|
|
|
14
|
-
COMMANDS = %w[generate init themes validate inspect doctor version help].freeze
|
|
19
|
+
COMMANDS = %w[generate run record replay new format compile schema completion watch init themes validate inspect doctor version help].freeze
|
|
15
20
|
|
|
16
21
|
def initialize(args)
|
|
17
22
|
@args = args.dup
|
|
@@ -28,6 +33,24 @@ module Shellfie
|
|
|
28
33
|
run_generate
|
|
29
34
|
when "init"
|
|
30
35
|
run_init
|
|
36
|
+
when "run"
|
|
37
|
+
run_session
|
|
38
|
+
when "record"
|
|
39
|
+
run_session(record: true)
|
|
40
|
+
when "replay"
|
|
41
|
+
replay_session
|
|
42
|
+
when "new"
|
|
43
|
+
run_new
|
|
44
|
+
when "format"
|
|
45
|
+
run_format
|
|
46
|
+
when "compile"
|
|
47
|
+
run_compile
|
|
48
|
+
when "schema"
|
|
49
|
+
run_schema
|
|
50
|
+
when "completion"
|
|
51
|
+
run_completion
|
|
52
|
+
when "watch"
|
|
53
|
+
run_watch
|
|
31
54
|
when "themes"
|
|
32
55
|
run_themes
|
|
33
56
|
when "validate"
|
|
@@ -46,15 +69,53 @@ module Shellfie
|
|
|
46
69
|
exit 1
|
|
47
70
|
end
|
|
48
71
|
rescue Shellfie::Error => e
|
|
49
|
-
|
|
72
|
+
if @options[:validation_format] && @options[:validation_format] != "text"
|
|
73
|
+
emit_validation_report(valid: false, error: e)
|
|
74
|
+
else
|
|
75
|
+
warn_error "Error: #{e.message}"
|
|
76
|
+
end
|
|
50
77
|
exit determine_exit_code(e)
|
|
51
78
|
rescue OptionParser::ParseError => e
|
|
52
79
|
warn_error "Error: #{e.message}"
|
|
53
80
|
exit 1
|
|
81
|
+
rescue SystemCallError => e
|
|
82
|
+
warn_error "Error: #{e.message}"
|
|
83
|
+
exit 5
|
|
54
84
|
end
|
|
55
85
|
|
|
56
86
|
private
|
|
57
87
|
|
|
88
|
+
def canonical_output_path(path)
|
|
89
|
+
expanded = File.expand_path(path)
|
|
90
|
+
missing = []
|
|
91
|
+
current = expanded
|
|
92
|
+
until File.exist?(current)
|
|
93
|
+
parent = File.dirname(current)
|
|
94
|
+
break if parent == current
|
|
95
|
+
|
|
96
|
+
missing.unshift(File.basename(current))
|
|
97
|
+
current = parent
|
|
98
|
+
end
|
|
99
|
+
File.join(File.realpath(current), *missing)
|
|
100
|
+
rescue Errno::ENOENT
|
|
101
|
+
expanded
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def configuration_version(path)
|
|
105
|
+
raw = YamlSafety.load_file(path, max_bytes: Parser::MAX_INCLUDE_BYTES)
|
|
106
|
+
raw.is_a?(Hash) ? raw[:version] : nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def replaceable_png_sequence_directory?(path)
|
|
110
|
+
entries = Dir.children(path)
|
|
111
|
+
entries.empty? || (entries.include?("timeline.json") &&
|
|
112
|
+
entries.all? { |entry| entry == "timeline.json" || entry.match?(/\Aframe_\d{4}\.png\z/) })
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def path_within?(path, directory)
|
|
116
|
+
path == directory || path.start_with?("#{directory}#{File::SEPARATOR}")
|
|
117
|
+
end
|
|
118
|
+
|
|
58
119
|
def warn_error(message)
|
|
59
120
|
$stderr.puts message
|
|
60
121
|
end
|
|
@@ -69,6 +130,8 @@ module Shellfie
|
|
|
69
130
|
4
|
|
70
131
|
when FileSystemError
|
|
71
132
|
5
|
|
133
|
+
when ExecutionError
|
|
134
|
+
6
|
|
72
135
|
else
|
|
73
136
|
1
|
|
74
137
|
end
|