spaceship 0.0.1 → 0.0.2

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,399 @@
1
+ require 'faraday' # HTTP Client
2
+ require 'faraday_middleware'
3
+ require 'spaceship/ui'
4
+ require 'spaceship/helper/plist_middleware'
5
+ require 'spaceship/helper/net_http_generic_request'
6
+
7
+ if ENV['DEBUG']
8
+ require 'pry' # TODO: Remove
9
+ require 'openssl'
10
+ OpenSSL::SSL::VERIFY_PEER = OpenSSL::SSL::VERIFY_NONE
11
+ end
12
+
13
+
14
+ module Spaceship
15
+ class Client
16
+ PROTOCOL_VERSION = "QH65B2"
17
+
18
+ attr_reader :client
19
+ attr_accessor :cookie
20
+
21
+ class InvalidUserCredentialsError < StandardError; end
22
+ class UnexpectedResponse < StandardError; end
23
+
24
+ # Authenticates with Apple's web services. This method has to be called once
25
+ # to generate a valid session. The session will automatically be used from then
26
+ # on.
27
+ #
28
+ # This method will automatically use the username from the Appfile (if available)
29
+ # and fetch the password from the Keychain (if available)
30
+ #
31
+ # @param user (String) (optional): The username (usually the email address)
32
+ # @param password (String) (optional): The password
33
+ #
34
+ # @raise InvalidUserCredentialsError: raised if authentication failed
35
+ #
36
+ # @return (Spaceship::Client) The client the login method was called for
37
+ def self.login(user = nil, password = nil)
38
+ instance = self.new
39
+ if instance.login(user, password)
40
+ instance
41
+ else
42
+ raise InvalidUserCredentialsError.new
43
+ end
44
+ end
45
+
46
+ def initialize
47
+ @client = Faraday.new("https://developer.apple.com/services-account/#{PROTOCOL_VERSION}/") do |c|
48
+ c.response :json, :content_type => /\bjson$/
49
+ c.response :xml, :content_type => /\bxml$/
50
+ c.response :plist, :content_type => /\bplist$/
51
+ c.adapter Faraday.default_adapter
52
+
53
+ if ENV['DEBUG']
54
+ # for debugging:
55
+ c.response :logger
56
+ c.proxy "https://127.0.0.1:8888"
57
+ end
58
+ end
59
+ end
60
+
61
+ def api_key
62
+ page = @client.get("https://developer.apple.com/devcenter/ios/index.action").body
63
+ if page =~ %r{<a href="https://idmsa.apple.com/IDMSWebAuth/login\?.*appIdKey=(\h+)}
64
+ return $1
65
+ end
66
+ end
67
+
68
+ # Automatic paging
69
+
70
+ def page_size
71
+ @page_size ||= 500
72
+ end
73
+
74
+ # Handles the paging for you... for free
75
+ # Just pass a block and use the parameter as page number
76
+ def paging
77
+ page = 0
78
+ results = []
79
+ loop do
80
+ page += 1
81
+ current = yield(page)
82
+
83
+ results = results + current
84
+
85
+ break if ((current || []).count < page_size) # no more results
86
+ end
87
+
88
+ return results
89
+ end
90
+
91
+ # Authenticates with Apple's web services. This method has to be called once
92
+ # to generate a valid session. The session will automatically be used from then
93
+ # on.
94
+ #
95
+ # This method will automatically use the username from the Appfile (if available)
96
+ # and fetch the password from the Keychain (if available)
97
+ #
98
+ # @param user (String) (optional): The username (usually the email address)
99
+ # @param password (String) (optional): The password
100
+ #
101
+ # @raise InvalidUserCredentialsError: raised if authentication failed
102
+ #
103
+ # @return (Spaceship::Client) The client the login method was called for
104
+ def login(user = nil, password = nil)
105
+ if user.to_s.empty? or password.to_s.empty?
106
+ require 'credentials_manager'
107
+ data = CredentialsManager::PasswordManager.shared_manager(user, false)
108
+ user ||= data.username
109
+ password ||= data.password
110
+ end
111
+
112
+ if user.to_s.empty? or password.to_s.empty?
113
+ raise InvalidUserCredentialsError.new("No login data provided")
114
+ end
115
+
116
+ response = request(:post, "https://idmsa.apple.com/IDMSWebAuth/authenticate", {
117
+ appleId: user,
118
+ accountPassword: password,
119
+ appIdKey: api_key
120
+ })
121
+
122
+ if response['Set-Cookie'] =~ /myacinfo=(\w+);/
123
+ @cookie = "myacinfo=#{$1};"
124
+ return @client
125
+ else
126
+ # User Credentials are wrong
127
+ raise InvalidUserCredentialsError.new(response)
128
+ end
129
+ end
130
+
131
+ def session?
132
+ !!@cookie
133
+ end
134
+
135
+ def teams
136
+ r = request(:post, 'account/listTeams.action')
137
+ parse_response(r, 'teams')
138
+ end
139
+
140
+ def team_id
141
+ return ENV['FASTLANE_TEAM_ID'] if ENV['FASTLANE_TEAM_ID']
142
+ return @current_team_id if @current_team_id
143
+
144
+ if teams.count > 1
145
+ puts "The current user is in #{teams.count} teams. Pass a team ID or call `select_team` to choose a team. Using the first one for now."
146
+ end
147
+ @current_team_id ||= teams[0]['teamId']
148
+ end
149
+
150
+ def select_team
151
+ @current_team_id = self.UI.select_team
152
+ end
153
+
154
+ def current_team_id=(team_id)
155
+ @current_team_id = team_id
156
+ end
157
+
158
+ def apps
159
+ paging do |page_number|
160
+ r = request(:post, 'account/ios/identifiers/listAppIds.action', {
161
+ teamId: team_id,
162
+ pageNumber: page_number,
163
+ pageSize: page_size,
164
+ sort: 'name=asc'
165
+ })
166
+ parse_response(r, 'appIds')
167
+ end
168
+ end
169
+
170
+ def create_app!(type, name, bundle_id)
171
+ ident_params = case type.to_sym
172
+ when :explicit
173
+ {
174
+ type: 'explicit',
175
+ explicitIdentifier: bundle_id,
176
+ appIdentifierString: bundle_id,
177
+ push: 'on',
178
+ inAppPurchase: 'on',
179
+ gameCenter: 'on'
180
+ }
181
+ when :wildcard
182
+ {
183
+ type: 'wildcard',
184
+ wildcardIdentifier: bundle_id,
185
+ appIdentifierString: bundle_id
186
+ }
187
+ end
188
+
189
+ params = {
190
+ appIdName: name,
191
+ teamId: team_id
192
+ }
193
+
194
+ params.merge!(ident_params)
195
+
196
+ r = request(:post, 'account/ios/identifiers/addAppId.action', params)
197
+ parse_response(r, 'appId')
198
+ end
199
+
200
+ def delete_app!(app_id)
201
+ r = request(:post, 'account/ios/identifiers/deleteAppId.action', {
202
+ teamId: team_id,
203
+ appIdId: app_id
204
+ })
205
+ parse_response(r)
206
+ end
207
+
208
+ def devices
209
+ paging do |page_number|
210
+ r = request(:post, 'account/ios/device/listDevices.action', {
211
+ teamId: team_id,
212
+ pageNumber: page_number,
213
+ pageSize: page_size,
214
+ sort: 'name=asc'
215
+ })
216
+ parse_response(r, 'devices')
217
+ end
218
+ end
219
+
220
+ def create_device!(device_name, device_id)
221
+ r = request(:post) do |r|
222
+ r.url "https://developerservices2.apple.com/services/#{PROTOCOL_VERSION}/ios/addDevice.action"
223
+ r.params = {
224
+ teamId: team_id,
225
+ deviceNumber: device_id,
226
+ name: device_name
227
+ }
228
+ end
229
+
230
+ parse_response(r, 'device')
231
+ end
232
+
233
+ def certificates(types)
234
+ paging do |page_number|
235
+ r = request(:post, 'account/ios/certificate/listCertRequests.action', {
236
+ teamId: team_id,
237
+ types: types.join(','),
238
+ pageNumber: page_number,
239
+ pageSize: page_size,
240
+ sort: 'certRequestStatusCode=asc'
241
+ })
242
+ parse_response(r, 'certRequests')
243
+ end
244
+ end
245
+
246
+ def create_certificate!(type, csr, app_id = nil)
247
+ r = request(:post, 'account/ios/certificate/submitCertificateRequest.action', {
248
+ teamId: team_id,
249
+ type: type,
250
+ csrContent: csr,
251
+ appIdId: app_id #optional
252
+ })
253
+ parse_response(r, 'certRequest')
254
+ end
255
+
256
+ def download_certificate(certificate_id, type)
257
+ {type: type, certificate_id: certificate_id}.each { |k, v| raise "#{k} must not be nil" if v.nil? }
258
+
259
+ r = request(:post, 'https://developer.apple.com/account/ios/certificate/certificateContentDownload.action', {
260
+ displayId: certificate_id,
261
+ type: type
262
+ })
263
+ parse_response(r)
264
+ end
265
+
266
+ def revoke_certificate!(certificate_id, type)
267
+ r = request(:post, 'account/ios/certificate/revokeCertificate.action', {
268
+ teamId: team_id,
269
+ certificateId: certificate_id,
270
+ type: type
271
+ })
272
+ parse_response(r, 'certRequests')
273
+ end
274
+
275
+ def provisioning_profiles
276
+ r = request(:post) do |r|
277
+ r.url "https://developerservices2.apple.com/services/#{PROTOCOL_VERSION}/ios/listProvisioningProfiles.action"
278
+ r.params = {
279
+ teamId: team_id,
280
+ includeInactiveProfiles: true,
281
+ onlyCountLists: true,
282
+ }
283
+ end
284
+
285
+ parse_response(r, 'provisioningProfiles')
286
+ end
287
+
288
+ def create_provisioning_profile!(name, distribution_method, app_id, certificate_ids, device_ids)
289
+ r = request(:post, 'account/ios/profile/createProvisioningProfile.action', {
290
+ teamId: team_id,
291
+ provisioningProfileName: name,
292
+ appIdId: app_id,
293
+ distributionType: distribution_method,
294
+ certificateIds: certificate_ids,
295
+ deviceIds: device_ids
296
+ })
297
+ parse_response(r, 'provisioningProfile')
298
+ end
299
+
300
+ def download_provisioning_profile(profile_id)
301
+ r = request(:get, 'https://developer.apple.com/account/ios/profile/profileContentDownload.action', {
302
+ teamId: team_id,
303
+ displayId: profile_id
304
+ })
305
+ parse_response(r)
306
+ end
307
+
308
+ def delete_provisioning_profile!(profile_id)
309
+ r = request(:post, 'account/ios/profile/deleteProvisioningProfile.action', {
310
+ teamId: team_id,
311
+ provisioningProfileId: profile_id
312
+ })
313
+ parse_response(r)
314
+ end
315
+
316
+ def repair_provisioning_profile!(profile_id, name, distribution_method, app_id, certificate_ids, device_ids)
317
+ r = request(:post, 'account/ios/profile/regenProvisioningProfile.action', {
318
+ teamId: team_id,
319
+ provisioningProfileId: profile_id,
320
+ provisioningProfileName: name,
321
+ appIdId: app_id,
322
+ distributionType: distribution_method,
323
+ certificateIds: certificate_ids,
324
+ deviceIds: device_ids
325
+ })
326
+
327
+ parse_response(r, 'provisioningProfile')
328
+ end
329
+
330
+ private
331
+ # Is called from `parse_response` to store
332
+ def store_csrf_tokens(response)
333
+ if response and response.headers
334
+ tokens = response.headers.select { |k, v| %w[csrf csrf_ts].include?(k) }
335
+ if tokens and not tokens.empty?
336
+ @csrf_tokens = tokens
337
+ end
338
+ end
339
+ end
340
+ ##
341
+ # memoize the last csrf tokens from responses
342
+ def csrf_tokens
343
+ @csrf_tokens || {}
344
+ end
345
+
346
+ def request(method, url_or_path = nil, params = nil, headers = {}, &block)
347
+ if session?
348
+ headers.merge!({'Cookie' => cookie})
349
+ headers.merge!(csrf_tokens)
350
+ end
351
+ headers.merge!({'User-Agent' => 'spaceship'})
352
+
353
+ # form-encode the params only if there are params, and the block is not supplied.
354
+ # this is so that certain requests can be made using the block for more control
355
+ if method == :post && params && !block_given?
356
+ params, headers = encode_params(params, headers)
357
+ end
358
+
359
+ send_request(method, url_or_path, params, headers, &block)
360
+ end
361
+
362
+ # Actually sends the request to the remote server
363
+ # Automatically retries the request up to 3 times if something goes wrong
364
+ def send_request(method, url_or_path, params, headers, &block)
365
+ tries ||= 5
366
+
367
+ return @client.send(method, url_or_path, params, headers, &block)
368
+
369
+ rescue Faraday::TimeoutError => ex
370
+ unless (tries -= 1).zero?
371
+ sleep 3
372
+ retry
373
+ end
374
+
375
+ raise ex # re-raise the exception
376
+ end
377
+
378
+ def parse_response(response, expected_key = nil)
379
+ if expected_key
380
+ content = response.body[expected_key]
381
+ else
382
+ content = response.body
383
+ end
384
+
385
+ if content.nil?
386
+ raise UnexpectedResponse.new(response.body)
387
+ else
388
+ store_csrf_tokens(response)
389
+ content
390
+ end
391
+ end
392
+
393
+ def encode_params(params, headers)
394
+ params = Faraday::Utils::ParamsHash[params].to_query
395
+ headers = {'Content-Type' => 'application/x-www-form-urlencoded'}.merge(headers)
396
+ return params, headers
397
+ end
398
+ end
399
+ end
@@ -0,0 +1,100 @@
1
+ module Spaceship
2
+ # Represents a device from the Apple Developer Portal
3
+ class Device < Base
4
+ # @return (String) The ID given from the developer portal. You'll probably not need it.
5
+ # @example
6
+ # "XJXGVS46MW"
7
+ attr_accessor :id
8
+
9
+ # @return (String) The name of the device
10
+ # @example
11
+ # "Felix Krause's iPhone 6"
12
+ attr_accessor :name
13
+
14
+ # @return (String) The UDID of the device
15
+ # @example
16
+ # "4c24a7ee5caaa4847f49aaab2d87483053f53b65"
17
+ attr_accessor :udid
18
+
19
+ # @return (String) The platform of the device. This is probably always "ios"
20
+ # @example
21
+ # "ios"
22
+ attr_accessor :platform
23
+
24
+ # @return (String) Status of the device. Probably always "c"
25
+ # @example
26
+ # "c"
27
+ attr_accessor :status
28
+
29
+ attr_mapping({
30
+ 'deviceId' => :id,
31
+ 'name' => :name,
32
+ 'deviceNumber' => :udid,
33
+ 'devicePlatform' => :platform,
34
+ 'status' => :status
35
+ })
36
+
37
+ class << self
38
+ # Create a new object based on a hash.
39
+ # This is used to create a new object based on the server response.
40
+ def factory(attrs)
41
+ self.new(attrs)
42
+ end
43
+
44
+ # @return (Array) Returns all devices registered for this account
45
+ def all
46
+ client.devices.map { |device| self.factory(device) }
47
+ end
48
+
49
+ # @return (Device) Find a device based on the ID of the device. *Attention*:
50
+ # This is *not* the UDID. nil if no device was found.
51
+ def find(device_id)
52
+ all.find do |device|
53
+ device.id == device_id
54
+ end
55
+ end
56
+
57
+ # @return (Device) Find a device based on the UDID of the device. nil if no device was found.
58
+ def find_by_udid(device_udid)
59
+ all.find do |device|
60
+ device.udid == device_udid
61
+ end
62
+ end
63
+
64
+ # @return (Device) Find a device based on its name. nil if no device was found.
65
+ def find_by_name(device_name)
66
+ all.find do |device|
67
+ device.name == device_name
68
+ end
69
+ end
70
+
71
+ # Register a new device to this account
72
+ # @param name (String) (required): The name of the new device
73
+ # @param udid (String) (required): The UDID of the new device
74
+ # @example
75
+ # Spaceship.device.create!(name: "Felix Krause's iPhone 6", udid: "4c24a7ee5caaa4847f49aaab2d87483053f53b65")
76
+ # @return (Device): The newly created device
77
+ def create!(name: nil, udid: nil)
78
+ # Check whether the user has passed in a UDID and a name
79
+ unless (udid and name)
80
+ raise "You cannot create a device without a device_id (UDID) and name"
81
+ end
82
+
83
+ # Find the device by UDID, raise an exception if it already exists
84
+ if self.find_by_udid(udid)
85
+ raise "The device UDID '#{udid}' already exists on this team."
86
+ end
87
+
88
+ # Find the device by name, raise an exception if it already exists
89
+ if self.find_by_name(name)
90
+ raise "The device name '#{name}' already exists on this team, use different one."
91
+ end
92
+
93
+ device = client.create_device!(name, udid)
94
+
95
+ # Update self with the new device
96
+ self.new(device)
97
+ end
98
+ end
99
+ end
100
+ end