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.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +390 -17
- data/lib/spaceship.rb +69 -3
- data/lib/spaceship/app.rb +96 -0
- data/lib/spaceship/base.rb +86 -0
- data/lib/spaceship/certificate.rb +270 -0
- data/lib/spaceship/client.rb +399 -0
- data/lib/spaceship/device.rb +100 -0
- data/lib/spaceship/helper/net_http_generic_request.rb +12 -0
- data/lib/spaceship/helper/plist_middleware.rb +15 -0
- data/lib/spaceship/launcher.rb +89 -0
- data/lib/spaceship/profile_types.rb +39 -0
- data/lib/spaceship/provisioning_profile.rb +352 -0
- data/lib/spaceship/ui.rb +28 -0
- data/lib/spaceship/ui/select_team.rb +55 -0
- data/lib/spaceship/version.rb +2 -2
- metadata +179 -33
- data/.gitignore +0 -14
- data/Gemfile +0 -4
- data/LICENSE.txt +0 -22
- data/Rakefile +0 -3
- data/lib/spaceship/ship.rb +0 -7
- data/spaceship.gemspec +0 -24
- data/spec/ship_spec.rb +0 -16
- data/spec/spec_helper.rb +0 -1
- data/tasks/rspec.rake +0 -3
@@ -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
|