ruflet_rails 0.0.7 → 0.0.8

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.
@@ -8,13 +8,13 @@ module Ruflet
8
8
  class Endpoint
9
9
  WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
10
10
 
11
- def initialize(server:, path: "/ws")
11
+ def initialize(server:, path: nil)
12
12
  @server = server
13
13
  @path = path
14
14
  end
15
15
 
16
16
  def call(env)
17
- return not_found unless env["PATH_INFO"] == @path
17
+ return not_found if @path && env["PATH_INFO"] != @path
18
18
  return bad_request("Expected WebSocket upgrade") unless websocket_upgrade_request?(env)
19
19
 
20
20
  hijack = env["rack.hijack"]
@@ -30,9 +30,7 @@ module Ruflet
30
30
  captured_env = env.dup
31
31
  Thread.new(io, captured_env) do |socket, ws_env|
32
32
  Thread.current.report_on_exception = false if Thread.current.respond_to?(:report_on_exception=)
33
- Context.with_env(ws_env) do
34
- @server.handle_upgraded_socket(socket)
35
- end
33
+ rails_executor_wrap { handle_socket(socket, ws_env) }
36
34
  end
37
35
 
38
36
  [-1, {}, []]
@@ -42,6 +40,24 @@ module Ruflet
42
40
 
43
41
  private
44
42
 
43
+ def handle_socket(socket, env)
44
+ Context.with_env(env) do
45
+ @server.handle_upgraded_socket(socket)
46
+ end
47
+ end
48
+
49
+ def rails_executor_wrap(&block)
50
+ executor = if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
51
+ ::Rails.application.executor
52
+ end
53
+
54
+ if executor.respond_to?(:wrap)
55
+ executor.wrap(&block)
56
+ else
57
+ yield
58
+ end
59
+ end
60
+
45
61
  def websocket_upgrade_request?(env)
46
62
  return false unless env["REQUEST_METHOD"] == "GET"
47
63
 
@@ -4,8 +4,9 @@ module Ruflet
4
4
  module Rails
5
5
  module Protocol
6
6
  class LocalServer
7
- def initialize(&app_block)
7
+ def initialize(session_registry: Ruflet::Rails.sessions, &app_block)
8
8
  @app_block = app_block
9
+ @session_registry = session_registry
9
10
  @sessions = {}
10
11
  @sessions_mutex = Mutex.new
11
12
  end
@@ -22,6 +23,7 @@ module Ruflet
22
23
  handle_message(ws, raw)
23
24
  end
24
25
  rescue StandardError => e
26
+ Rails.logger.error("RUFLET CRASH: #{e.class}: #{e.message}\n#{e.backtrace.first(10).join("\n")}") if defined?(Rails)
25
27
  send_message(ws, Ruflet::Protocol::ACTIONS[:session_crashed], { "message" => e.message.to_s.dup.force_encoding("UTF-8") })
26
28
  ensure
27
29
  close_connection(ws)
@@ -35,7 +37,8 @@ module Ruflet
35
37
  end
36
38
 
37
39
  def remove_session(ws)
38
- @sessions_mutex.synchronize { @sessions.delete(ws.session_key) }
40
+ page = @sessions_mutex.synchronize { @sessions.delete(ws.session_key) }
41
+ @session_registry.remove(page.session_id, connection_key: ws.session_key) if page
39
42
  end
40
43
 
41
44
  def handle_message(ws, raw)
@@ -98,17 +101,29 @@ module Ruflet
98
101
  def on_register_client(ws, payload)
99
102
  normalized = Ruflet::Protocol.normalize_register_payload(payload)
100
103
  session_id = normalized["session_id"].to_s.empty? ? pseudo_uuid : normalized["session_id"]
104
+ existing_session = @session_registry[session_id]
105
+ page = existing_session&.page
106
+ first_registration = page.nil?
101
107
 
102
- page = Ruflet::Page.new(
103
- session_id: session_id,
104
- client_details: normalized,
105
- sender: lambda do |action, msg_payload|
106
- send_message(ws, action, msg_payload)
107
- end
108
- )
109
- page.title = "Ruflet App"
108
+ if page
109
+ attach_sender(page, ws)
110
+ reset_mount_state(page)
111
+ else
112
+ page = Ruflet::Page.new(
113
+ session_id: session_id,
114
+ client_details: normalized,
115
+ sender: sender_for(ws)
116
+ )
117
+ page.title = "Ruflet App"
118
+ end
110
119
 
111
120
  @sessions_mutex.synchronize { @sessions[ws.session_key] = page }
121
+ @session_registry.add(
122
+ key: page.session_id,
123
+ page: page,
124
+ env: Context.current_env,
125
+ connection_key: ws.session_key
126
+ )
112
127
 
113
128
  initial_response = [
114
129
  Ruflet::Protocol::ACTIONS[:register_client],
@@ -116,19 +131,20 @@ module Ruflet
116
131
  ]
117
132
  ws.send_binary(WireCodec.pack(initial_response))
118
133
 
119
- @app_block.call(page)
134
+ @app_block.call(page) if first_registration
120
135
  page.update
121
136
  rescue StandardError => e
122
137
  send_message(ws, Ruflet::Protocol::ACTIONS[:session_crashed], { "message" => e.message.to_s })
123
138
  raise
124
139
  end
125
140
 
126
-
127
141
  def on_control_event(ws, payload)
128
142
  event = Ruflet::Protocol.normalize_control_event_payload(payload)
129
143
  page = fetch_page(ws)
130
144
  return if event["target"].nil? || event["name"].to_s.empty?
131
145
 
146
+ attach_sender(page, ws)
147
+ debug_event(ws, event)
132
148
  page.dispatch_event(
133
149
  target: event["target"],
134
150
  name: event["name"],
@@ -141,11 +157,13 @@ module Ruflet
141
157
  page = fetch_page(ws)
142
158
  return if update["id"].nil?
143
159
 
160
+ attach_sender(page, ws)
144
161
  page.apply_client_update(update["id"], update["props"] || {})
145
162
  end
146
163
 
147
164
  def on_invoke_control_method(ws, payload)
148
165
  page = fetch_page(ws)
166
+ attach_sender(page, ws)
149
167
  page.handle_invoke_method_result(Ruflet::Protocol.normalize_invoke_method_result_payload(payload))
150
168
  end
151
169
 
@@ -173,6 +191,31 @@ module Ruflet
173
191
  nil
174
192
  end
175
193
 
194
+ def sender_for(ws)
195
+ lambda do |action, msg_payload|
196
+ send_message(ws, action, msg_payload)
197
+ end
198
+ end
199
+
200
+ def attach_sender(page, ws)
201
+ page.instance_variable_set(:@sender, sender_for(ws))
202
+ end
203
+
204
+ def debug_event(ws, event)
205
+ return unless ENV["RUFLET_RAILS_DEBUG_EVENTS"] == "true"
206
+
207
+ warn(
208
+ "[ruflet_rails] event socket=#{ws.session_key} " \
209
+ "target=#{event["target"].inspect} name=#{event["name"].inspect} data=#{event["data"].inspect}"
210
+ )
211
+ end
212
+
213
+ def reset_mount_state(page)
214
+ page.instance_variable_set(:@overlay_container_mounted, false)
215
+ page.instance_variable_set(:@dialogs_container_mounted, false)
216
+ page.instance_variable_set(:@services_container_mounted, false)
217
+ end
218
+
176
219
  def pseudo_uuid
177
220
  now = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
178
221
  rnd = rand(0..0xffff_ffff)
@@ -8,27 +8,40 @@ module Ruflet
8
8
  @entrypoint = entrypoint
9
9
  end
10
10
 
11
- def build_endpoint(path: "/")
11
+ def build_endpoint(path: nil)
12
12
  raise ArgumentError, "Ruflet::Rails::Protocol endpoint requires a block" unless @entrypoint.respond_to?(:call)
13
13
 
14
14
  Endpoint.new(server: build_server(@entrypoint), path: path)
15
15
  end
16
16
 
17
- def build_mobile_endpoint(file_path:, path: "/")
18
- loaded = MobileLoader.new(file_path).load!
19
- Endpoint.new(server: build_server(loaded[:entrypoint]), path: path)
17
+ def build_app_endpoint(file_path:, path: nil)
18
+ absolute = File.expand_path(file_path)
19
+ entrypoint = lambda do |page, env|
20
+ loaded = MobileLoader.new(absolute).load!
21
+ run_entrypoint(loaded[:entrypoint], page, env)
22
+ end
23
+
24
+ Endpoint.new(
25
+ server: build_server(entrypoint),
26
+ path: path
27
+ )
20
28
  end
29
+ alias build_mobile_endpoint build_app_endpoint
21
30
 
22
31
  private
23
32
 
24
33
  def build_server(entrypoint)
25
34
  LocalServer.new do |page|
26
35
  env = Context.current_env
27
- if entrypoint.arity == 1
28
- entrypoint.call(page)
29
- else
30
- entrypoint.call(page, env)
31
- end
36
+ run_entrypoint(entrypoint, page, env)
37
+ end
38
+ end
39
+
40
+ def run_entrypoint(entrypoint, page, env)
41
+ if entrypoint.arity == 1
42
+ entrypoint.call(page)
43
+ else
44
+ entrypoint.call(page, env)
32
45
  end
33
46
  end
34
47
  end
@@ -1,126 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ruflet_server"
4
+
3
5
  module Ruflet
4
6
  module Rails
5
7
  module Protocol
6
- class WebSocketConnection
7
- def initialize(socket)
8
- @socket = socket
9
- @write_mutex = Mutex.new
10
- end
11
-
12
- def session_key
13
- @socket.object_id
14
- end
15
-
16
- def closed?
17
- @socket.closed?
18
- rescue IOError
19
- true
20
- end
21
-
22
- def send_binary(payload)
23
- send_frame(0x2, payload.to_s.b)
24
- end
25
-
26
- def read_message
27
- frame = read_frame
28
- return nil if frame.nil?
29
-
30
- opcode = frame[:opcode]
31
- payload = frame[:payload]
32
-
33
- case opcode
34
- when 0x8
35
- close
36
- nil
37
- when 0x9
38
- send_frame(0xA, payload)
39
- read_message
40
- when 0xA
41
- read_message
42
- when 0x1, 0x2
43
- payload
44
- else
45
- read_message
46
- end
47
- end
48
-
49
- def close
50
- return if closed?
51
-
52
- @socket.close
53
- rescue IOError
54
- nil
55
- end
56
-
57
- private
58
-
59
- def read_frame
60
- header = read_exact(2)
61
- return nil if header.nil?
62
-
63
- b1 = header.getbyte(0)
64
- b2 = header.getbyte(1)
65
-
66
- masked = (b2 & 0x80) != 0
67
- payload_len = b2 & 0x7f
68
-
69
- payload_len = read_exact(2).unpack1("n") if payload_len == 126
70
- payload_len = read_exact(8).unpack1("Q>") if payload_len == 127
71
-
72
- masking_key = masked ? read_exact(4) : nil
73
- payload = payload_len.zero? ? "".b : read_exact(payload_len)
74
- return nil if payload.nil?
75
-
76
- payload = unmask(payload, masking_key) if masked
77
-
78
- { opcode: b1 & 0x0f, payload: payload }
79
- end
80
-
81
- def send_frame(opcode, payload)
82
- bytes = payload.to_s.b
83
- len = bytes.bytesize
84
- header = [0x80 | (opcode & 0x0f)].pack("C")
85
-
86
- header <<
87
- if len <= 125
88
- [len].pack("C")
89
- elsif len <= 0xffff
90
- [126].pack("C") + [len].pack("n")
91
- else
92
- [127].pack("C") + [len].pack("Q>")
93
- end
94
-
95
- @write_mutex.synchronize do
96
- @socket.write(header)
97
- @socket.write(bytes) unless bytes.empty?
98
- end
99
- end
100
-
101
- def unmask(payload, mask)
102
- out = +""
103
- out.force_encoding(Encoding::BINARY)
104
- payload.bytes.each_with_index do |byte, idx|
105
- out << (byte ^ mask.getbyte(idx % 4))
106
- end
107
- out
108
- end
109
-
110
- def read_exact(length)
111
- chunk = +""
112
- chunk.force_encoding(Encoding::BINARY)
113
-
114
- while chunk.bytesize < length
115
- part = @socket.read(length - chunk.bytesize)
116
- return nil if part.nil? || part.empty?
117
-
118
- chunk << part
119
- end
120
-
121
- chunk
122
- end
123
- end
8
+ WebSocketConnection = ::Ruflet::WebSocketConnection unless const_defined?(:WebSocketConnection, false)
124
9
  end
125
10
  end
126
11
  end
@@ -1,251 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ruflet_server"
4
+
3
5
  module Ruflet
4
6
  module Rails
5
7
  module Protocol
6
- class WireCodec
7
- class << self
8
- def pack(value)
9
- case value
10
- when NilClass
11
- "\xc0".b
12
- when TrueClass
13
- "\xc3".b
14
- when FalseClass
15
- "\xc2".b
16
- when Integer
17
- pack_integer(value)
18
- when Float
19
- "\xcb".b + [value].pack("G")
20
- when String
21
- binary_string?(value) ? pack_binary(value) : pack_string(value)
22
- when Symbol
23
- pack_string(value.to_s)
24
- when Array
25
- pack_array(value)
26
- when Hash
27
- pack_map(value)
28
- else
29
- pack_string(value.to_s)
30
- end
31
- end
32
-
33
- def unpack(bytes)
34
- reader = ByteReader.new(bytes)
35
- read_value(reader)
36
- end
37
-
38
- private
39
-
40
- def pack_integer(value)
41
- if value >= 0
42
- return [value].pack("C") if value <= 0x7f
43
- return "\xcc".b + [value].pack("C") if value <= 0xff
44
- return "\xcd".b + [value].pack("n") if value <= 0xffff
45
- return "\xce".b + [value].pack("N") if value <= 0xffff_ffff
46
-
47
- "\xcf".b + [value].pack("Q>")
48
- else
49
- return [value & 0xff].pack("C") if value >= -32
50
- return "\xd0".b + [value].pack("c") if value >= -128
51
- return "\xd1".b + [value].pack("s>") if value >= -32_768
52
- return "\xd2".b + [value].pack("l>") if value >= -2_147_483_648
53
-
54
- "\xd3".b + [value].pack("q>")
55
- end
56
- end
57
-
58
- def pack_string(value)
59
- str = value.to_s.dup.force_encoding("UTF-8")
60
- bytes = str.b
61
- len = bytes.bytesize
62
-
63
- if len <= 31
64
- [0xA0 | len].pack("C") + bytes
65
- elsif len <= 0xff
66
- "\xd9".b + [len].pack("C") + bytes
67
- elsif len <= 0xffff
68
- "\xda".b + [len].pack("n") + bytes
69
- else
70
- "\xdb".b + [len].pack("N") + bytes
71
- end
72
- end
73
-
74
- def pack_binary(value)
75
- bytes = value.to_s.b
76
- len = bytes.bytesize
77
-
78
- if len <= 0xff
79
- "\xc4".b + [len].pack("C") + bytes
80
- elsif len <= 0xffff
81
- "\xc5".b + [len].pack("n") + bytes
82
- else
83
- "\xc6".b + [len].pack("N") + bytes
84
- end
85
- end
86
-
87
- def binary_string?(value)
88
- value.encoding == Encoding::BINARY || !value.valid_encoding?
89
- end
90
-
91
- def pack_array(value)
92
- len = value.length
93
- head =
94
- if len <= 15
95
- [0x90 | len].pack("C")
96
- elsif len <= 0xffff
97
- "\xdc".b + [len].pack("n")
98
- else
99
- "\xdd".b + [len].pack("N")
100
- end
101
-
102
- body = +"".b
103
- value.each { |item| body << pack(item) }
104
- head + body
105
- end
106
-
107
- def pack_map(value)
108
- pairs = value.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
109
- len = pairs.length
110
- head =
111
- if len <= 15
112
- [0x80 | len].pack("C")
113
- elsif len <= 0xffff
114
- "\xde".b + [len].pack("n")
115
- else
116
- "\xdf".b + [len].pack("N")
117
- end
118
-
119
- body = +"".b
120
- pairs.each do |k, v|
121
- body << pack(k)
122
- body << pack(v)
123
- end
124
- head + body
125
- end
126
-
127
- def read_value(reader)
128
- marker = reader.read_u8
129
-
130
- return marker if marker <= 0x7f
131
- return marker - 256 if marker >= 0xe0
132
-
133
- case marker
134
- when 0xc0 then nil
135
- when 0xc2 then false
136
- when 0xc3 then true
137
- when 0xcc then reader.read_u8
138
- when 0xcd then reader.read_u16
139
- when 0xce then reader.read_u32
140
- when 0xcf then reader.read_u64
141
- when 0xd0 then reader.read_i8
142
- when 0xd1 then reader.read_i16
143
- when 0xd2 then reader.read_i32
144
- when 0xd3 then reader.read_i64
145
- when 0xca then reader.read_f32
146
- when 0xcb then reader.read_f64
147
- when 0xd9 then reader.read_string(reader.read_u8)
148
- when 0xda then reader.read_string(reader.read_u16)
149
- when 0xdb then reader.read_string(reader.read_u32)
150
- when 0xc4 then reader.read_binary(reader.read_u8)
151
- when 0xc5 then reader.read_binary(reader.read_u16)
152
- when 0xc6 then reader.read_binary(reader.read_u32)
153
- when 0xdc then read_array(reader, reader.read_u16)
154
- when 0xdd then read_array(reader, reader.read_u32)
155
- when 0xde then read_map(reader, reader.read_u16)
156
- when 0xdf then read_map(reader, reader.read_u32)
157
- when 0xd4
158
- read_ext(reader, 1)
159
- when 0xd5
160
- read_ext(reader, 2)
161
- when 0xd6
162
- read_ext(reader, 4)
163
- when 0xd7
164
- read_ext(reader, 8)
165
- when 0xd8
166
- read_ext(reader, 16)
167
- when 0xc7
168
- read_ext(reader, reader.read_u8)
169
- when 0xc8
170
- read_ext(reader, reader.read_u16)
171
- when 0xc9
172
- read_ext(reader, reader.read_u32)
173
- else
174
- if (marker & 0xf0) == 0x90
175
- read_array(reader, marker & 0x0f)
176
- elsif (marker & 0xf0) == 0x80
177
- read_map(reader, marker & 0x0f)
178
- elsif (marker & 0xe0) == 0xa0
179
- reader.read_string(marker & 0x1f)
180
- else
181
- raise "Unsupported MessagePack marker: 0x#{marker.to_s(16)}"
182
- end
183
- end
184
- end
185
-
186
- def read_array(reader, size)
187
- Array.new(size) { read_value(reader) }
188
- end
189
-
190
- def read_map(reader, size)
191
- out = {}
192
- size.times do
193
- key = read_value(reader)
194
- out[key.to_s] = read_value(reader)
195
- end
196
- out
197
- end
198
-
199
- def read_ext(reader, size)
200
- type = reader.read_i8
201
- data = reader.read_exact(size)
202
-
203
- case type
204
- when 1, 2, 4
205
- data.dup.force_encoding("UTF-8")
206
- when 3
207
- data.to_i
208
- else
209
- data
210
- end
211
- end
212
- end
213
-
214
- class ByteReader
215
- def initialize(bytes)
216
- @data = bytes.to_s.b
217
- @offset = 0
218
- end
219
-
220
- def read_u8
221
- value = @data.getbyte(@offset)
222
- raise "Unexpected EOF" if value.nil?
223
-
224
- @offset += 1
225
- value
226
- end
227
-
228
- def read_exact(size)
229
- chunk = @data.byteslice(@offset, size)
230
- raise "Unexpected EOF" if chunk.nil? || chunk.bytesize != size
231
-
232
- @offset += size
233
- chunk
234
- end
235
-
236
- def read_u16 = read_exact(2).unpack1("n")
237
- def read_u32 = read_exact(4).unpack1("N")
238
- def read_u64 = read_exact(8).unpack1("Q>")
239
- def read_i8 = read_exact(1).unpack1("c")
240
- def read_i16 = read_exact(2).unpack1("s>")
241
- def read_i32 = read_exact(4).unpack1("l>")
242
- def read_i64 = read_exact(8).unpack1("q>")
243
- def read_f32 = read_exact(4).unpack1("g")
244
- def read_f64 = read_exact(8).unpack1("G")
245
- def read_string(size) = read_exact(size).force_encoding("UTF-8")
246
- def read_binary(size) = read_exact(size)
247
- end
248
- end
8
+ WireCodec = ::Ruflet::WireCodec unless const_defined?(:WireCodec, false)
249
9
  end
250
10
  end
251
11
  end
@@ -7,19 +7,78 @@ module Ruflet
7
7
  app.middleware.use Ruflet::Rails::Protocol::Middleware
8
8
  end
9
9
 
10
+ initializer "ruflet_rails.desktop_launcher", after: :load_config_initializers do |_app|
11
+ next unless defined?(::Rails.root)
12
+
13
+ Ruflet::Rails::DesktopLauncher.launch_once(root: ::Rails.root)
14
+ end
15
+
10
16
  rake_tasks do
11
17
  namespace :ruflet do
12
18
  desc "Build Ruflet client for this Rails app. Usage: rake ruflet:build[web]"
13
19
  task :build, [:platform] do |_task, args|
14
- platform = args[:platform].to_s.strip
15
- if platform.empty?
16
- warn "Usage: rake ruflet:build[apk|android|ios|aab|web|macos|windows|linux]"
20
+ requested_platform = args[:platform].to_s.strip.downcase
21
+ build_args = Ruflet::Rails::InstallSupport.build_args_for_platform(requested_platform)
22
+ platform = build_args.first
23
+ if platform.to_s.empty?
24
+ warn "Usage: rake ruflet:build[apk|android|ios|aab|web|desktop|macos|windows|linux]"
17
25
  next
18
26
  end
19
27
 
20
28
  require "ruflet/cli"
21
29
  exit_code = Dir.chdir(::Rails.root) do
22
- Ruflet::CLI.command_build([platform])
30
+ Ruflet::CLI.command_build(build_args)
31
+ end
32
+ raise SystemExit, exit_code unless exit_code.to_i.zero?
33
+
34
+ if platform == "web"
35
+ published = Ruflet::Rails::InstallSupport.publish_web_build(::Rails.root.to_s)
36
+ if published
37
+ puts "Ruflet web client published at /#{Ruflet::Rails::InstallSupport.default_web_public_path}/"
38
+ else
39
+ warn "Ruflet web build completed, but build/web was not found to publish."
40
+ end
41
+ end
42
+ end
43
+
44
+ desc "Download/update prebuilt Ruflet clients from GitHub releases. Usage: rake ruflet:update[web|desktop|all]"
45
+ task :update, [:target] do |_task, args|
46
+ target = args[:target].to_s.strip
47
+ if target.empty?
48
+ warn "Usage: rake ruflet:update[web|desktop|all]"
49
+ next
50
+ end
51
+ normalized_target = target.downcase
52
+ platform = Ruflet::Rails::InstallSupport.host_desktop_platform
53
+
54
+ require "ruflet/cli"
55
+ exit_code = Dir.chdir(::Rails.root) do
56
+ Ruflet::CLI.command_update([target])
57
+ end
58
+ raise SystemExit, exit_code unless exit_code.to_i.zero?
59
+
60
+ if %w[web all].include?(normalized_target)
61
+ published = Ruflet::Rails::InstallSupport.publish_prebuilt_web_client(
62
+ ::Rails.root.to_s,
63
+ platform: platform
64
+ )
65
+ if published
66
+ puts "Ruflet web client published at /#{Ruflet::Rails::InstallSupport.default_web_public_path}/"
67
+ else
68
+ warn "Ruflet web client downloaded, but no prebuilt web index.html was found to publish."
69
+ end
70
+ end
71
+ end
72
+
73
+ desc "Install the last built Ruflet mobile app onto a device. Usage: rake ruflet:install[DEVICE_ID]"
74
+ task :install, [:device] do |_task, args|
75
+ argv = []
76
+ device = args[:device].to_s.strip
77
+ argv += ["--device", device] unless device.empty?
78
+
79
+ require "ruflet/cli"
80
+ exit_code = Dir.chdir(::Rails.root) do
81
+ Ruflet::CLI.command_install(argv)
23
82
  end
24
83
  raise SystemExit, exit_code unless exit_code.to_i.zero?
25
84
  end