Skip to content

Commit 5ad72b2

Browse files
authored
Command naming consistency (#53)
1 parent 8cb52d4 commit 5ad72b2

File tree

15 files changed

+482
-471
lines changed

15 files changed

+482
-471
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
require 'dry/cli'
2+
require 'cfpropertylist'
3+
require 'zip'
4+
require 'rbconfig'
5+
require 'tmpdir'
6+
7+
module EmergeCLI
8+
module Commands
9+
module Build
10+
module Distribution
11+
class Install < EmergeCLI::Commands::GlobalOptions
12+
desc 'Download and install a build from Build Distribution'
13+
14+
option :api_token, type: :string, required: false,
15+
desc: 'API token for authentication, defaults to ENV[EMERGE_API_TOKEN]'
16+
option :build_id, type: :string, required: true, desc: 'Build ID to download'
17+
option :install, type: :boolean, default: true, required: false, desc: 'Install the build on the device'
18+
option :device_id, type: :string, desc: 'Specific device ID to target'
19+
option :device_type, type: :string, enum: %w[virtual physical any], default: 'any',
20+
desc: 'Type of device to target (virtual/physical/any)'
21+
option :output, type: :string, required: false, desc: 'Output path for the downloaded build'
22+
23+
def initialize(network: nil)
24+
@network = network
25+
end
26+
27+
def call(**options)
28+
@options = options
29+
before(options)
30+
31+
Sync do
32+
api_token = @options[:api_token] || ENV.fetch('EMERGE_API_TOKEN', nil)
33+
raise 'API token is required' unless api_token
34+
35+
raise 'Build ID is required' unless @options[:build_id]
36+
37+
output_name = nil
38+
app_id = nil
39+
40+
begin
41+
@network ||= EmergeCLI::Network.new(api_token:)
42+
43+
Logger.info 'Getting build URL...'
44+
request = get_build_url(@options[:build_id])
45+
response = parse_response(request)
46+
47+
platform = response['platform']
48+
download_url = response['downloadUrl']
49+
app_id = response['appId']
50+
51+
extension = platform == 'ios' ? 'ipa' : 'apk'
52+
Logger.info 'Downloading build...'
53+
output_name = @options[:output] || "#{@options[:build_id]}.#{extension}"
54+
`curl --progress-bar -L '#{download_url}' -o #{output_name} `
55+
Logger.info "✅ Build downloaded to #{output_name}"
56+
rescue StandardError => e
57+
Logger.error "❌ Failed to download build: #{e.message}"
58+
raise e
59+
ensure
60+
@network&.close
61+
end
62+
63+
begin
64+
if @options[:install] && !output_name.nil?
65+
if platform == 'ios'
66+
install_ios_build(output_name, app_id)
67+
elsif platform == 'android'
68+
install_android_build(output_name)
69+
end
70+
end
71+
rescue StandardError => e
72+
Logger.error "❌ Failed to install build: #{e.message}"
73+
raise e
74+
end
75+
end
76+
end
77+
78+
private
79+
80+
def get_build_url(build_id)
81+
@network.get(
82+
path: '/distribution/downloadUrl',
83+
max_retries: 3,
84+
query: {
85+
buildId: build_id
86+
}
87+
)
88+
end
89+
90+
def parse_response(response)
91+
case response.status
92+
when 200
93+
JSON.parse(response.read)
94+
when 400
95+
error_message = JSON.parse(response.read)['errorMessage']
96+
raise "Invalid parameters: #{error_message}"
97+
when 401, 403
98+
raise 'Invalid API token'
99+
else
100+
raise "Getting build failed with status #{response.status}"
101+
end
102+
end
103+
104+
def install_ios_build(build_path, app_id)
105+
device_type = case @options[:device_type]
106+
when 'simulator'
107+
XcodeDeviceManager::DeviceType::VIRTUAL
108+
when 'physical'
109+
XcodeDeviceManager::DeviceType::PHYSICAL
110+
else
111+
XcodeDeviceManager::DeviceType::ANY
112+
end
113+
114+
device_manager = XcodeDeviceManager.new
115+
device = if @options[:device_id]
116+
device_manager.find_device_by_id(@options[:device_id])
117+
else
118+
device_manager.find_device_by_type(device_type, build_path)
119+
end
120+
121+
Logger.info "Installing build on #{device.device_id}"
122+
device.install_app(build_path)
123+
Logger.info '✅ Build installed'
124+
125+
Logger.info "Launching app #{app_id}..."
126+
device.launch_app(app_id)
127+
Logger.info '✅ Build launched'
128+
end
129+
130+
def install_android_build(build_path)
131+
command = "adb -s #{@options[:device_id]} install #{build_path}"
132+
Logger.debug "Running command: #{command}"
133+
`#{command}`
134+
135+
Logger.info '✅ Build installed'
136+
end
137+
end
138+
end
139+
end
140+
end
141+
end
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
require 'dry/cli'
2+
require 'cfpropertylist'
3+
require 'zip'
4+
require 'rbconfig'
5+
6+
module EmergeCLI
7+
module Commands
8+
module Build
9+
module Distribution
10+
class ValidateApp < EmergeCLI::Commands::GlobalOptions
11+
desc 'Validate app for build distribution'
12+
13+
option :path, type: :string, required: true, desc: 'Path to the xcarchive, IPA or APK to validate'
14+
15+
# Constants
16+
PLIST_START = '<plist'.freeze
17+
PLIST_STOP = '</plist>'.freeze
18+
19+
UTF8_ENCODING = 'UTF-8'.freeze
20+
STRING_FORMAT = 'binary'.freeze
21+
EMPTY_STRING = ''.freeze
22+
23+
EXPECTED_ABI = 'arm64-v8a'.freeze
24+
25+
def call(**options)
26+
@options = options
27+
before(options)
28+
29+
Sync do
30+
file_extension = File.extname(@options[:path])
31+
case file_extension
32+
when '.xcarchive'
33+
handle_xcarchive
34+
when '.ipa'
35+
handle_ipa
36+
when '.app'
37+
handle_app
38+
when '.apk'
39+
handle_apk
40+
else
41+
raise "Unknown file extension: #{file_extension}"
42+
end
43+
end
44+
end
45+
46+
private
47+
48+
def handle_xcarchive
49+
raise 'Path must be an xcarchive' unless @options[:path].end_with?('.xcarchive')
50+
51+
app_path = Dir.glob("#{@options[:path]}/Products/Applications/*.app").first
52+
run_codesign_check(app_path)
53+
read_provisioning_profile(app_path)
54+
end
55+
56+
def handle_ipa
57+
raise 'Path must be an IPA' unless @options[:path].end_with?('.ipa')
58+
59+
Dir.mktmpdir do |tmp_dir|
60+
Zip::File.open(@options[:path]) do |zip_file|
61+
zip_file.each do |entry|
62+
entry.extract(File.join(tmp_dir, entry.name))
63+
end
64+
end
65+
66+
app_path = File.join(tmp_dir, 'Payload/*.app')
67+
app_path = Dir.glob(app_path).first
68+
run_codesign_check(app_path)
69+
read_provisioning_profile(app_path)
70+
end
71+
end
72+
73+
def handle_app
74+
raise 'Path must be an app' unless @options[:path].end_with?('.app')
75+
76+
app_path = @options[:path]
77+
run_codesign_check(app_path)
78+
read_provisioning_profile(app_path)
79+
end
80+
81+
def handle_apk
82+
raise 'Path must be an APK' unless @options[:path].end_with?('.apk')
83+
84+
apk_path = @options[:path]
85+
check_supported_abis(apk_path)
86+
end
87+
88+
def run_codesign_check(app_path)
89+
unless RbConfig::CONFIG['host_os'] =~ /darwin/i
90+
Logger.info 'Skipping codesign check on non-macOS platform'
91+
return
92+
end
93+
94+
command = "codesign -dvvv '#{app_path}'"
95+
Logger.debug command
96+
stdout, _, status = Open3.capture3(command)
97+
Logger.debug stdout
98+
raise '❌ Codesign check failed' unless status.success?
99+
100+
Logger.info '✅ Codesign check passed'
101+
end
102+
103+
def read_provisioning_profile(app_path)
104+
entitlements_path = File.join(app_path, 'embedded.mobileprovision')
105+
raise '❌ Entitlements file not found' unless File.exist?(entitlements_path)
106+
107+
content = File.read(entitlements_path)
108+
lines = content.lines
109+
110+
buffer = ''
111+
inside_plist = false
112+
lines.each do |line|
113+
inside_plist = true if line.include? PLIST_START
114+
if inside_plist
115+
buffer << line
116+
break if line.include? PLIST_STOP
117+
end
118+
end
119+
120+
encoded_plist = buffer.encode(UTF8_ENCODING, STRING_FORMAT, invalid: :replace, undef: :replace,
121+
replace: EMPTY_STRING)
122+
encoded_plist = encoded_plist.sub(/#{PLIST_STOP}.+/, PLIST_STOP)
123+
124+
plist = CFPropertyList::List.new(data: encoded_plist)
125+
parsed_data = CFPropertyList.native_types(plist.value)
126+
127+
expiration_date = parsed_data['ExpirationDate']
128+
if expiration_date > Time.now
129+
Logger.info '✅ Provisioning profile hasn\'t expired'
130+
else
131+
Logger.info "❌ Provisioning profile is expired. Expiration date: #{expiration_date}"
132+
end
133+
134+
provisions_all_devices = parsed_data['ProvisionsAllDevices']
135+
if provisions_all_devices
136+
Logger.info 'Provisioning profile supports all devices (likely an enterprise profile)'
137+
else
138+
devices = parsed_data['ProvisionedDevices']
139+
Logger.info 'Provisioning profile does not support all devices (likely a development profile).'
140+
Logger.info "Devices: #{devices.inspect}"
141+
end
142+
end
143+
144+
def check_supported_abis(apk_path)
145+
abis = []
146+
147+
Zip::File.open(apk_path) do |zip_file|
148+
zip_file.each do |entry|
149+
if entry.name.start_with?('lib/') && entry.name.count('/') == 2
150+
abi = entry.name.split('/')[1]
151+
abis << abi unless abis.include?(abi)
152+
end
153+
end
154+
end
155+
156+
unless abis.include?(EXPECTED_ABI)
157+
raise "APK does not support #{EXPECTED_ABI} architecture, found: #{abis.join(', ')}"
158+
end
159+
160+
Logger.info "✅ APK supports #{EXPECTED_ABI} architecture"
161+
end
162+
end
163+
end
164+
end
165+
end
166+
end

0 commit comments

Comments
 (0)