emerge 0.2.2 → 0.4.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.
@@ -0,0 +1,325 @@
1
+ require 'macho'
2
+
3
+ module EmergeCLI
4
+ class MachOParser
5
+ TYPE_METADATA_KIND_MASK = 0x7 << 3
6
+ TYPE_METADATA_KIND_SHIFT = 3
7
+
8
+ # Bind Codes
9
+ BIND_OPCODE_MASK = 0xF0
10
+ BIND_IMMEDIATE_MASK = 0x0F
11
+ BIND_OPCODE_DONE = 0x00
12
+ BIND_OPCODE_SET_DYLIB_ORDINAL_IMM = 0x10
13
+ BIND_OPCODE_SET_DYLIB_ORDINAL_ULEB = 0x20
14
+ BIND_OPCODE_SET_DYLIB_SPECIAL_IMM = 0x30
15
+ BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM = 0x40
16
+ BIND_OPCODE_SET_TYPE_IMM = 0x50
17
+ BIND_OPCODE_SET_ADDEND_SLEB = 0x60
18
+ BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB = 0x70
19
+ BIND_OPCODE_ADD_ADDR_ULEB = 0x80
20
+ BIND_OPCODE_DO_BIND = 0x90
21
+ BIND_OPCODE_DO_BIND_ADD_ADDR_ULEB = 0xA0
22
+ BIND_OPCODE_DO_BIND_ADD_ADDR_IMM_SCALED = 0xB0
23
+ BIND_OPCODE_DO_BIND_ULEB_TIMES_SKIPPING_ULEB = 0xC0
24
+
25
+ UINT64_SIZE = 8
26
+ UINT64_MAX_VALUE = 0xFFFFFFFFFFFFFFFF
27
+
28
+ def load_binary(binary_path)
29
+ @macho_file = MachO::MachOFile.new(binary_path)
30
+ @binary_data = File.binread(binary_path)
31
+ end
32
+
33
+ def read_linkedit_data_command
34
+ chained_fixups_command = nil
35
+ @macho_file.load_commands.each do |lc|
36
+ chained_fixups_command = lc if lc.type == :LC_DYLD_CHAINED_FIXUPS
37
+ end
38
+
39
+ if chained_fixups_command.nil?
40
+ Logger.debug 'No LC_DYLD_CHAINED_FIXUPS found'
41
+ return false, []
42
+ end
43
+
44
+ # linkedit_data_command
45
+ _, _, dataoff, datasize = @binary_data[chained_fixups_command.offset, 16].unpack('L<L<L<L<')
46
+
47
+ header = @binary_data[dataoff, datasize].unpack('L<L<L<L<L<L<L<')
48
+ # dyld_chained_fixups_header
49
+ _, _, imports_offset, symbols_offset, imports_count,
50
+ imports_format, = header
51
+
52
+ imports_start = dataoff + imports_offset
53
+ symbols_start = dataoff + symbols_offset
54
+
55
+ imported_symbols = []
56
+
57
+ import_size, name_offset_proc =
58
+ case imports_format
59
+ when 1, nil # DYLD_CHAINED_IMPORT
60
+ [4, ->(ptr) { ptr.unpack1('L<') >> 9 }]
61
+ when 2 # DYLD_CHAINED_IMPORT_ADDEND
62
+ [8, ->(ptr) { ptr.unpack1('L<') >> 9 }]
63
+ when 3 # DYLD_CHAINED_IMPORT_ADDEND64
64
+ [16, ->(ptr) { ptr.unpack1('Q<') >> 32 }]
65
+ end
66
+
67
+ # Extract imported symbol names
68
+ imports_count.times do |i|
69
+ import_offset = imports_start + (i * import_size)
70
+ name_offset = name_offset_proc.call(@binary_data[import_offset, import_size])
71
+ name_start = symbols_start + name_offset
72
+ name = read_null_terminated_string(@binary_data[name_start..])
73
+ imported_symbols << name
74
+ end
75
+
76
+ [true, imported_symbols]
77
+ end
78
+
79
+ def read_dyld_info_only_command
80
+ dyld_info_only_command = nil
81
+ @macho_file.load_commands.each do |lc|
82
+ dyld_info_only_command = lc if lc.type == :LC_DYLD_INFO_ONLY
83
+ end
84
+
85
+ if dyld_info_only_command.nil?
86
+ Logger.debug 'No LC_DYLD_INFO_ONLY found'
87
+ return []
88
+ end
89
+
90
+ bound_symbols = []
91
+ start_address = dyld_info_only_command.bind_off
92
+ end_address = dyld_info_only_command.bind_off + dyld_info_only_command.bind_size
93
+ current_address = start_address
94
+
95
+ current_symbol = BoundSymbol.new(segment_offset: 0, library: nil, offset: 0, symbol: '')
96
+ while current_address < end_address
97
+ results, current_address, current_symbol = read_next_symbol(@binary_data, current_address, end_address,
98
+ current_symbol)
99
+
100
+ # Dup items to avoid pointer issues
101
+ results.each do |res|
102
+ bound_symbols << res.dup
103
+ end
104
+ end
105
+
106
+ # Filter only swift symbols starting with _$s
107
+ swift_symbols = bound_symbols.select { |bound_symbol| bound_symbol.symbol.start_with?('_$s') }
108
+
109
+ load_commands = @macho_file.load_commands.select { |lc| lc.type == :LC_SEGMENT_64 || lc.type == :LC_SEGMENT } # rubocop:disable Naming/VariableNumber
110
+
111
+ swift_symbols.each do |swift_symbol|
112
+ swift_symbol.address = load_commands[swift_symbol.segment_offset].vmaddr + swift_symbol.offset
113
+ end
114
+
115
+ swift_symbols
116
+ end
117
+
118
+ def find_protocols_in_swift_proto(use_chained_fixups, imported_symbols, bound_symbols, search_symbols)
119
+ found_section = nil
120
+ @macho_file.segments.each do |segment|
121
+ segment.sections.each do |section|
122
+ if section.segname.strip == '__TEXT' && section.sectname.strip == '__swift5_proto'
123
+ found_section = section
124
+ break
125
+ end
126
+ end
127
+ end
128
+
129
+ unless found_section
130
+ Logger.error 'The __swift5_proto section was not found.'
131
+ return false
132
+ end
133
+
134
+ start = found_section.offset
135
+ size = found_section.size
136
+ offsets_list = parse_list(@binary_data, start, size)
137
+
138
+ offsets_list.each do |relative_offset, offset_start|
139
+ type_file_address = offset_start + relative_offset
140
+ if type_file_address <= 0 || type_file_address >= @binary_data.size
141
+ Logger.error 'Invalid protocol conformance offset'
142
+ next
143
+ end
144
+
145
+ # ProtocolConformanceDescriptor -> ProtocolDescriptor
146
+ protocol_descriptor = read_little_endian_signed_integer(@binary_data, type_file_address)
147
+
148
+ # # ProtocolConformanceDescriptor -> ConformanceFlags
149
+ conformance_flags = read_little_endian_signed_integer(@binary_data, type_file_address + 12)
150
+ kind = (conformance_flags & TYPE_METADATA_KIND_MASK) >> TYPE_METADATA_KIND_SHIFT
151
+
152
+ next unless kind == 0
153
+
154
+ indirect_relative_offset = get_indirect_relative_offset(type_file_address, protocol_descriptor)
155
+
156
+ bound_symbol = bound_symbols.find { |symbol| symbol.address == indirect_relative_offset }
157
+ if bound_symbol
158
+ return true if search_symbols.include?(bound_symbol.symbol)
159
+ elsif use_chained_fixups
160
+ descriptor_offset = protocol_descriptor & ~1
161
+ jump_ptr = type_file_address + descriptor_offset
162
+
163
+ address = @binary_data[jump_ptr, 4].unpack1('I<')
164
+ symbol_name = imported_symbols[address]
165
+ return true if search_symbols.include?(symbol_name)
166
+ end
167
+ end
168
+ false
169
+ end
170
+
171
+ private
172
+
173
+ def read_next_symbol(binary_data, current_address, end_address, current_symbol)
174
+ while current_address < end_address
175
+ first_byte = read_byte(binary_data, current_address)
176
+ current_address += 1
177
+ immediate = first_byte & BIND_IMMEDIATE_MASK
178
+ opcode = first_byte & BIND_OPCODE_MASK
179
+
180
+ case opcode
181
+ when BIND_OPCODE_DONE
182
+ result = current_symbol.dup
183
+ current_symbol.segment_offset = 0
184
+ current_symbol.library = 0
185
+ current_symbol.offset = 0
186
+ current_symbol.symbol = ''
187
+ return [result], current_address, current_symbol
188
+ when BIND_OPCODE_SET_DYLIB_ORDINAL_IMM
189
+ current_symbol.library = [immediate].pack('L').unpack1('L')
190
+ when BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM
191
+ current_symbol.symbol = read_null_terminated_string(binary_data[current_address..])
192
+ # Increase current pointer
193
+ current_address += current_symbol.symbol.size + 1
194
+ when BIND_OPCODE_ADD_ADDR_ULEB
195
+ offset, new_current_address = read_uleb(@binary_data, current_address)
196
+ current_symbol.offset = (current_symbol.offset + offset) & UINT64_MAX_VALUE
197
+ current_address = new_current_address
198
+ when BIND_OPCODE_DO_BIND_ADD_ADDR_ULEB
199
+ offset, new_current_address = read_uleb(@binary_data, current_address)
200
+ current_symbol.offset = (current_symbol.offset + offset + UINT64_SIZE) & UINT64_MAX_VALUE
201
+ current_address = new_current_address
202
+ return [current_symbol], current_address, current_symbol
203
+ when BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB
204
+ offset, current_address = read_uleb(@binary_data, current_address)
205
+ current_symbol.segment_offset = immediate
206
+ current_symbol.offset = offset
207
+ when BIND_OPCODE_SET_ADDEND_SLEB
208
+ _, current_address = read_uleb(@binary_data, current_address)
209
+ when BIND_OPCODE_DO_BIND_ADD_ADDR_IMM_SCALED
210
+ result = current_symbol.dup
211
+ current_symbol.offset = (
212
+ current_symbol.offset + (immediate * UINT64_SIZE) + UINT64_SIZE
213
+ ) & UINT64_MAX_VALUE
214
+ return [result], current_address, current_symbol
215
+ when BIND_OPCODE_DO_BIND_ULEB_TIMES_SKIPPING_ULEB
216
+ count, current_address = read_uleb(@binary_data, current_address)
217
+ skipping, current_address = read_uleb(@binary_data, current_address)
218
+
219
+ results = []
220
+ count.times do
221
+ results << current_symbol.dup
222
+ current_symbol.offset = (current_symbol.offset + skipping + UINT64_SIZE) & UINT64_MAX_VALUE
223
+ end
224
+
225
+ return results, current_address, current_symbol
226
+ when BIND_OPCODE_SET_DYLIB_ORDINAL_ULEB
227
+ count, current_address = read_uleb(@binary_data, current_address)
228
+ current_symbol.library = count
229
+ when BIND_OPCODE_DO_BIND
230
+ result = current_symbol.dup
231
+ current_symbol.offset = (current_symbol.offset + UINT64_SIZE) & UINT64_MAX_VALUE
232
+ return [result], current_address, current_symbol
233
+ end
234
+ end
235
+ [[], current_address, current_symbol]
236
+ end
237
+
238
+ def read_byte(binary_data, address)
239
+ binary_data[address, 1].unpack1('C')
240
+ end
241
+
242
+ def read_little_endian_signed_integer(binary_data, address)
243
+ binary_data[address, 4].unpack1('l<')
244
+ end
245
+
246
+ def read_uleb(binary_data, address)
247
+ next_byte = 0
248
+ size = 0
249
+ result = 0
250
+
251
+ loop do
252
+ next_byte = read_byte(binary_data, address)
253
+ address += 1
254
+ bytes = next_byte & 0x7F
255
+ shifted = bytes << (size * 7)
256
+
257
+ size += 1
258
+ result |= shifted
259
+ break if next_byte.nobits?(0x80)
260
+ end
261
+
262
+ [result, address]
263
+ end
264
+
265
+ def read_null_terminated_string(data)
266
+ data.unpack1('Z*')
267
+ end
268
+
269
+ def vm_address(file_offset, macho)
270
+ load_commands = macho.load_commands.select { |lc| lc.type == :LC_SEGMENT_64 || lc.type == :LC_SEGMENT } # rubocop:disable Naming/VariableNumber
271
+ load_commands.each do |lc|
272
+ next unless file_offset >= lc.fileoff && file_offset < (lc.fileoff + lc.filesize)
273
+ unless lc.respond_to?(:sections)
274
+ Logger.error 'Load command does not support sections function'
275
+ next
276
+ end
277
+
278
+ lc.sections.each do |section|
279
+ if file_offset >= section.offset && file_offset < (section.offset) + section.size
280
+ return section.addr + (file_offset - section.offset)
281
+ end
282
+ end
283
+ end
284
+ nil
285
+ end
286
+
287
+ def parse_list(bytes, start, size)
288
+ data_pointer = bytes[start..]
289
+ file_offset = start
290
+ pointer_size = 4
291
+ class_pointers = []
292
+
293
+ (size / pointer_size).to_i.times do
294
+ pointer = data_pointer.unpack1('l<')
295
+ class_pointers << [pointer, file_offset]
296
+ data_pointer = data_pointer[pointer_size..]
297
+ file_offset += pointer_size
298
+ end
299
+
300
+ class_pointers
301
+ end
302
+
303
+ def get_indirect_relative_offset(type_file_address, protocol_descriptor)
304
+ vm_start = vm_address(type_file_address, @macho_file)
305
+ return nil if vm_start.nil?
306
+ if (vm_start + protocol_descriptor).odd?
307
+ (vm_start + protocol_descriptor) & ~1
308
+ elsif vm_start + protocol_descriptor > 0
309
+ vm_start + protocol_descriptor
310
+ end
311
+ end
312
+ end
313
+
314
+ class BoundSymbol
315
+ attr_accessor :segment_offset, :library, :offset, :symbol, :address
316
+
317
+ def initialize(segment_offset:, library:, offset:, symbol:)
318
+ @segment_offset = segment_offset
319
+ @library = library
320
+ @offset = offset
321
+ @symbol = symbol
322
+ @address = 0
323
+ end
324
+ end
325
+ end
data/lib/utils/network.rb CHANGED
@@ -11,22 +11,22 @@ module EmergeCLI
11
11
  RETRY_DELAY = 5
12
12
  MAX_RETRIES = 3
13
13
 
14
- def initialize(api_token:, base_url: EMERGE_API_PROD_URL)
14
+ def initialize(api_token: nil, base_url: EMERGE_API_PROD_URL)
15
15
  @base_url = base_url
16
16
  @api_token = api_token
17
17
  @internet = Async::HTTP::Internet.new
18
18
  end
19
19
 
20
- def get(path:, headers: {})
21
- request(:get, path, nil, headers)
20
+ def get(path:, headers: {}, max_retries: MAX_RETRIES)
21
+ request(:get, path, nil, headers, nil, max_retries)
22
22
  end
23
23
 
24
- def post(path:, body:, headers: {})
25
- request(:post, path, body, headers)
24
+ def post(path:, body:, headers: {}, query: nil, max_retries: MAX_RETRIES)
25
+ request(:post, path, body, headers, query, max_retries)
26
26
  end
27
27
 
28
- def put(path:, body:, headers: {})
29
- request(:put, path, body, headers)
28
+ def put(path:, body:, headers: {}, max_retries: MAX_RETRIES)
29
+ request(:put, path, body, headers, nil, max_retries)
30
30
  end
31
31
 
32
32
  def close
@@ -35,18 +35,23 @@ module EmergeCLI
35
35
 
36
36
  private
37
37
 
38
- def request(method, path, body, custom_headers)
38
+ def request(method, path, body, custom_headers, query = nil, max_retries = MAX_RETRIES)
39
39
  uri = if path.start_with?('http')
40
40
  URI.parse(path)
41
41
  else
42
- URI::HTTPS.build(host: @base_url, path:)
42
+ query_string = query ? URI.encode_www_form(query) : nil
43
+ URI::HTTPS.build(
44
+ host: @base_url,
45
+ path: path,
46
+ query: query_string
47
+ )
43
48
  end
44
49
  absolute_uri = uri.to_s
45
50
 
46
51
  headers = {
47
- 'X-API-Token' => @api_token,
48
52
  'User-Agent' => "emerge-cli/#{EmergeCLI::VERSION}"
49
53
  }
54
+ headers['X-API-Token'] = @api_token if @api_token
50
55
  headers['Content-Type'] = 'application/json' if method == :post && body.is_a?(Hash)
51
56
  headers.merge!(custom_headers)
52
57
 
@@ -66,10 +71,10 @@ module EmergeCLI
66
71
  response
67
72
  rescue StandardError => e
68
73
  retries += 1
69
- if retries <= MAX_RETRIES
74
+ if retries <= max_retries
70
75
  delay = RETRY_DELAY * retries
71
76
  error_message = e.message
72
- Logger.warn "Request failed (attempt #{retries}/#{MAX_RETRIES}): #{error_message}"
77
+ Logger.warn "Request failed (attempt #{retries}/#{max_retries}): #{error_message}"
73
78
  Logger.warn "Retrying in #{delay} seconds..."
74
79
 
75
80
  begin
@@ -82,7 +87,9 @@ module EmergeCLI
82
87
  sleep delay
83
88
  retry
84
89
  else
85
- Logger.error "Request failed after #{MAX_RETRIES} attempts: #{absolute_uri} #{e.message}"
90
+ unless max_retries == 0
91
+ Logger.error "Request failed after #{max_retries} attempts: #{absolute_uri} #{e.message}"
92
+ end
86
93
  raise e
87
94
  end
88
95
  end
@@ -0,0 +1,32 @@
1
+ require 'json'
2
+
3
+ module EmergeCLI
4
+ module Utils
5
+ class VersionCheck
6
+ def initialize(network: EmergeCLI::Network.new)
7
+ @network = network
8
+ end
9
+
10
+ def check_version
11
+ Sync do
12
+ response = @network.get(
13
+ path: 'https://rubygems.org/api/v1/gems/emerge.json',
14
+ headers: {}
15
+ )
16
+ latest_version = JSON.parse(response.read).fetch('version')
17
+ current_version = EmergeCLI::VERSION
18
+
19
+ if Gem::Version.new(latest_version) > Gem::Version.new(current_version)
20
+ Logger.warn "A new version of emerge-cli is available (#{latest_version})"
21
+ Logger.warn "You are currently using version #{current_version}"
22
+ Logger.warn "To update, run: gem update emerge\n"
23
+ end
24
+ end
25
+ rescue KeyError
26
+ Logger.error 'Failed to parse version from RubyGems API response'
27
+ rescue StandardError => e
28
+ Logger.error "Failed to check for updates: #{e.message}"
29
+ end
30
+ end
31
+ end
32
+ end
data/lib/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module EmergeCLI
2
- VERSION = '0.2.2'.freeze
2
+ VERSION = '0.4.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,43 +1,49 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: emerge
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Emerge Tools
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-12-02 00:00:00.000000000 Z
11
+ date: 2024-12-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: async
14
+ name: async-http
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: 2.21.1
19
+ version: 0.86.0
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: 2.21.1
26
+ version: 0.86.0
27
27
  - !ruby/object:Gem::Dependency
28
- name: async-http
28
+ name: CFPropertyList
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: 0.86.0
33
+ version: '2.3'
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: 2.3.2
34
37
  type: :runtime
35
38
  prerelease: false
36
39
  version_requirements: !ruby/object:Gem::Requirement
37
40
  requirements:
38
41
  - - "~>"
39
42
  - !ruby/object:Gem::Version
40
- version: 0.86.0
43
+ version: '2.3'
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 2.3.2
41
47
  - !ruby/object:Gem::Dependency
42
48
  name: chunky_png
43
49
  requirement: !ruby/object:Gem::Requirement
@@ -81,19 +87,19 @@ dependencies:
81
87
  - !ruby/object:Gem::Version
82
88
  version: 0.2.1
83
89
  - !ruby/object:Gem::Dependency
84
- name: pry-byebug
90
+ name: ruby-macho
85
91
  requirement: !ruby/object:Gem::Requirement
86
92
  requirements:
87
93
  - - "~>"
88
94
  - !ruby/object:Gem::Version
89
- version: '3.8'
95
+ version: 4.1.0
90
96
  type: :runtime
91
97
  prerelease: false
92
98
  version_requirements: !ruby/object:Gem::Requirement
93
99
  requirements:
94
100
  - - "~>"
95
101
  - !ruby/object:Gem::Version
96
- version: '3.8'
102
+ version: 4.1.0
97
103
  - !ruby/object:Gem::Dependency
98
104
  name: ruby_tree_sitter
99
105
  requirement: !ruby/object:Gem::Requirement
@@ -165,6 +171,10 @@ files:
165
171
  - lib/commands/config/snapshots/snapshots_ios.rb
166
172
  - lib/commands/global_options.rb
167
173
  - lib/commands/integrate/fastlane.rb
174
+ - lib/commands/order_files/download_order_files.rb
175
+ - lib/commands/order_files/validate_linkmaps.rb
176
+ - lib/commands/reaper/reaper.rb
177
+ - lib/commands/snapshots/validate_app.rb
168
178
  - lib/commands/upload/snapshots/client_libraries/default.rb
169
179
  - lib/commands/upload/snapshots/client_libraries/paparazzi.rb
170
180
  - lib/commands/upload/snapshots/client_libraries/roborazzi.rb
@@ -172,15 +182,24 @@ files:
172
182
  - lib/commands/upload/snapshots/snapshots.rb
173
183
  - lib/emerge_cli.rb
174
184
  - lib/reaper/ast_parser.rb
185
+ - lib/reaper/code_deleter.rb
175
186
  - lib/utils/git.rb
176
187
  - lib/utils/git_info_provider.rb
177
188
  - lib/utils/git_result.rb
178
189
  - lib/utils/github.rb
179
190
  - lib/utils/logger.rb
191
+ - lib/utils/macho_parser.rb
180
192
  - lib/utils/network.rb
181
193
  - lib/utils/profiler.rb
182
194
  - lib/utils/project_detector.rb
195
+ - lib/utils/version_check.rb
183
196
  - lib/version.rb
197
+ - parsers/libtree-sitter-java-darwin-arm64.dylib
198
+ - parsers/libtree-sitter-java-linux-x86_64.so
199
+ - parsers/libtree-sitter-kotlin-darwin-arm64.dylib
200
+ - parsers/libtree-sitter-kotlin-linux-x86_64.so
201
+ - parsers/libtree-sitter-swift-darwin-arm64.dylib
202
+ - parsers/libtree-sitter-swift-linux-x86_64.so
184
203
  homepage: https://github.com/EmergeTools/emerge-cli
185
204
  licenses:
186
205
  - MIT
@@ -204,7 +223,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
204
223
  - !ruby/object:Gem::Version
205
224
  version: '0'
206
225
  requirements: []
207
- rubygems_version: 3.4.10
226
+ rubygems_version: 3.5.11
208
227
  signing_key:
209
228
  specification_version: 4
210
229
  summary: Emerge CLI