emerge 0.2.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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