mysigner 0.1.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.
- checksums.yaml +7 -0
- data/.DS_Store +0 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/.travis.yml +7 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +137 -0
- data/LICENSE +201 -0
- data/MANUAL_TEST.md +341 -0
- data/README.md +493 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/exe/mysigner +5 -0
- data/lib/mysigner/build/android_executor.rb +367 -0
- data/lib/mysigner/build/android_parser.rb +293 -0
- data/lib/mysigner/build/configurator.rb +126 -0
- data/lib/mysigner/build/detector.rb +388 -0
- data/lib/mysigner/build/error_analyzer.rb +193 -0
- data/lib/mysigner/build/executor.rb +176 -0
- data/lib/mysigner/build/parser.rb +206 -0
- data/lib/mysigner/cli/auth_commands.rb +1381 -0
- data/lib/mysigner/cli/build_commands.rb +2095 -0
- data/lib/mysigner/cli/concerns/actionable_suggestions.rb +500 -0
- data/lib/mysigner/cli/concerns/api_helpers.rb +131 -0
- data/lib/mysigner/cli/concerns/error_handlers.rb +446 -0
- data/lib/mysigner/cli/concerns/helpers.rb +63 -0
- data/lib/mysigner/cli/diagnostic_commands.rb +1034 -0
- data/lib/mysigner/cli/resource_commands.rb +2670 -0
- data/lib/mysigner/cli.rb +43 -0
- data/lib/mysigner/client.rb +189 -0
- data/lib/mysigner/config.rb +311 -0
- data/lib/mysigner/export/exporter.rb +150 -0
- data/lib/mysigner/signing/certificate_checker.rb +148 -0
- data/lib/mysigner/signing/keystore_manager.rb +239 -0
- data/lib/mysigner/signing/validator.rb +150 -0
- data/lib/mysigner/signing/wizard.rb +784 -0
- data/lib/mysigner/upload/app_store_automation.rb +402 -0
- data/lib/mysigner/upload/app_store_submission.rb +312 -0
- data/lib/mysigner/upload/play_store_uploader.rb +378 -0
- data/lib/mysigner/upload/uploader.rb +373 -0
- data/lib/mysigner/version.rb +3 -0
- data/lib/mysigner.rb +15 -0
- data/mysigner.gemspec +78 -0
- data/test_manual.rb +102 -0
- metadata +286 -0
|
@@ -0,0 +1,1381 @@
|
|
|
1
|
+
module Mysigner
|
|
2
|
+
class CLI < Thor
|
|
3
|
+
module AuthCommands
|
|
4
|
+
def self.included(base)
|
|
5
|
+
base.class_eval do
|
|
6
|
+
desc "version", "Show CLI version and system information"
|
|
7
|
+
def version
|
|
8
|
+
say "My Signer CLI v#{Mysigner::VERSION}", :cyan
|
|
9
|
+
say ""
|
|
10
|
+
say "Ruby: #{RUBY_VERSION} (#{RUBY_PLATFORM})", :white
|
|
11
|
+
say "Install: #{File.expand_path('../../../..', __FILE__)}", :white
|
|
12
|
+
say "Config: #{Config::CONFIG_FILE}", :white
|
|
13
|
+
say ""
|
|
14
|
+
say "Repository: https://github.com/yourusername/my-signer-cli", :white
|
|
15
|
+
say "Issues: https://github.com/yourusername/my-signer-cli/issues", :white
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "login", "Log in with existing API token (⭐ first-timers: use 'onboard' instead)"
|
|
19
|
+
long_desc <<~DESC
|
|
20
|
+
Authenticate with My Signer API using an API token.
|
|
21
|
+
|
|
22
|
+
New user? Run 'mysigner onboard' for step-by-step guidance.
|
|
23
|
+
|
|
24
|
+
Your credentials will be stored securely in ~/.mysigner/config.yml
|
|
25
|
+
|
|
26
|
+
Note: API tokens are organization-specific. This token will only
|
|
27
|
+
grant access to the organization it was created in.
|
|
28
|
+
DESC
|
|
29
|
+
def login
|
|
30
|
+
# Check if already logged in
|
|
31
|
+
config = Config.new
|
|
32
|
+
if config.exists?
|
|
33
|
+
config.load
|
|
34
|
+
say "⚠️ Already logged in", :yellow
|
|
35
|
+
say ""
|
|
36
|
+
say "Current configuration:", :yellow
|
|
37
|
+
say " User: #{config.user_email || '(unknown)'}"
|
|
38
|
+
say " Organization: #{config.org_name || '(unknown)'} (ID: #{config.current_organization_id})"
|
|
39
|
+
say " API URL: #{config.api_url}"
|
|
40
|
+
say ""
|
|
41
|
+
|
|
42
|
+
if yes?("Do you want to logout and login with different user? (y/n)")
|
|
43
|
+
config.clear
|
|
44
|
+
say "✓ Logged out successfully", :green
|
|
45
|
+
say ""
|
|
46
|
+
else
|
|
47
|
+
say "Login cancelled. Use 'mysigner logout' to logout first.", :yellow
|
|
48
|
+
say ""
|
|
49
|
+
say "💡 Tip: Use 'mysigner switch' to switch organizations for the same user", :yellow
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
say "🔐 My Signer Login", :cyan
|
|
55
|
+
say "=" * 80, :cyan
|
|
56
|
+
say ""
|
|
57
|
+
|
|
58
|
+
# Get API URL with smart default
|
|
59
|
+
api_url = prompt_api_url
|
|
60
|
+
say ""
|
|
61
|
+
|
|
62
|
+
# Get user email
|
|
63
|
+
user_email = prompt_for_email
|
|
64
|
+
say ""
|
|
65
|
+
|
|
66
|
+
# Show guidance for getting token
|
|
67
|
+
show_token_guidance(api_url)
|
|
68
|
+
|
|
69
|
+
api_token = ask("API Token:", echo: false)
|
|
70
|
+
say "" # New line after hidden input
|
|
71
|
+
|
|
72
|
+
if api_token.empty?
|
|
73
|
+
error "API token cannot be empty"
|
|
74
|
+
say ""
|
|
75
|
+
say "💡 Tip: Run 'mysigner onboard' for detailed guidance", :yellow
|
|
76
|
+
exit 1
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
say "Validating token and email...", :yellow
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
client = Client.new(api_url: api_url, api_token: api_token, user_email: user_email)
|
|
83
|
+
response = client.test_connection
|
|
84
|
+
|
|
85
|
+
if response[:success]
|
|
86
|
+
say "✓ Token valid", :green
|
|
87
|
+
else
|
|
88
|
+
error "Connection failed"
|
|
89
|
+
handle_connection_failure(api_url)
|
|
90
|
+
exit 1
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Fetch organization info (token can only access its own org)
|
|
94
|
+
say "Detecting organization...", :yellow
|
|
95
|
+
|
|
96
|
+
# Try to fetch organizations - with org-specific tokens, this will return only the token's org
|
|
97
|
+
orgs_response = client.get('/api/v1/organizations')
|
|
98
|
+
organizations = orgs_response[:data]['organizations']
|
|
99
|
+
|
|
100
|
+
if ENV['DEBUG']
|
|
101
|
+
say "DEBUG: Found #{organizations.length} organizations", :cyan
|
|
102
|
+
organizations.each do |org|
|
|
103
|
+
say "DEBUG: - #{org['name']} (ID: #{org['id']})", :cyan
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if organizations.empty?
|
|
108
|
+
error "No organizations found for this token"
|
|
109
|
+
say ""
|
|
110
|
+
say "This might mean:", :yellow
|
|
111
|
+
say " • Your token doesn't have access to any organizations", :yellow
|
|
112
|
+
say " • The token was created but the organization was deleted", :yellow
|
|
113
|
+
say ""
|
|
114
|
+
show_create_org_guidance(api_url)
|
|
115
|
+
exit 1
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# With org-specific tokens, there should only be one organization
|
|
119
|
+
selected_org = organizations.first
|
|
120
|
+
org_id = selected_org['id']
|
|
121
|
+
|
|
122
|
+
say "DEBUG: Fetching details for organization #{org_id}...", :cyan if ENV['DEBUG']
|
|
123
|
+
|
|
124
|
+
# Get detailed org info to extract user email and token_organization_id
|
|
125
|
+
org_response = client.get("/api/v1/organizations/#{org_id}")
|
|
126
|
+
org_data = org_response[:data]
|
|
127
|
+
|
|
128
|
+
say "DEBUG: Organization data received", :cyan if ENV['DEBUG']
|
|
129
|
+
|
|
130
|
+
say "✓ Organization detected: #{org_data['name']}", :green
|
|
131
|
+
say "✓ Email validated: #{user_email}", :green
|
|
132
|
+
say ""
|
|
133
|
+
|
|
134
|
+
# Save configuration with multi-token support
|
|
135
|
+
config = Config.new
|
|
136
|
+
config.api_url = api_url
|
|
137
|
+
config.user_email = user_email # Save the verified email
|
|
138
|
+
config.current_organization_id = org_id
|
|
139
|
+
config.save_token_for_org(org_id, org_data['name'], api_token)
|
|
140
|
+
config.save
|
|
141
|
+
|
|
142
|
+
say ""
|
|
143
|
+
say "=" * 80, :green
|
|
144
|
+
say "✓ Successfully logged in!", :green
|
|
145
|
+
say "=" * 80, :green
|
|
146
|
+
say ""
|
|
147
|
+
say "Organization: #{org_data['name']} (ID: #{org_id})", :cyan
|
|
148
|
+
say "Role: #{org_data['role'] || 'viewer'}", :cyan
|
|
149
|
+
say "Config saved to: #{Config::CONFIG_FILE}", :cyan
|
|
150
|
+
say ""
|
|
151
|
+
say "🔒 Security Note:", :yellow
|
|
152
|
+
say " Your token is organization-specific and can only access", :yellow
|
|
153
|
+
say " #{org_data['name']}. To access other organizations,", :yellow
|
|
154
|
+
say " use 'mysigner switch' to add tokens for those organizations.", :yellow
|
|
155
|
+
say ""
|
|
156
|
+
say "🚀 Next steps:", :bold
|
|
157
|
+
say " cd your-ios-project"
|
|
158
|
+
say " mysigner ship testflight"
|
|
159
|
+
say ""
|
|
160
|
+
say "💡 Helpful commands:", :cyan
|
|
161
|
+
say " • mysigner doctor - Check your environment"
|
|
162
|
+
say " • mysigner orgs - List all organizations"
|
|
163
|
+
say " • mysigner switch - Switch to another organization"
|
|
164
|
+
say ""
|
|
165
|
+
|
|
166
|
+
rescue Mysigner::UnauthorizedError => e
|
|
167
|
+
error "Authentication failed"
|
|
168
|
+
say ""
|
|
169
|
+
|
|
170
|
+
# Check if it's an email validation error
|
|
171
|
+
if e.message.include?("doesn't belong to") || e.message.include?("use your own token")
|
|
172
|
+
say "⚠️ Token Email Mismatch", :yellow
|
|
173
|
+
say ""
|
|
174
|
+
say "The token you provided doesn't belong to #{user_email}.", :yellow
|
|
175
|
+
say ""
|
|
176
|
+
say "This could mean:", :yellow
|
|
177
|
+
say " • You're using a token created by someone else", :yellow
|
|
178
|
+
say " • You're using a token from a different account", :yellow
|
|
179
|
+
say ""
|
|
180
|
+
say "💡 Solutions:", :cyan
|
|
181
|
+
say " 1. Generate a new token from your own account at:", :cyan
|
|
182
|
+
say " #{api_url}", :cyan
|
|
183
|
+
say " 2. Make sure you're logged in as #{user_email} on the web", :cyan
|
|
184
|
+
say " 3. Check that you entered the correct email address", :cyan
|
|
185
|
+
else
|
|
186
|
+
handle_unauthorized_error(api_url)
|
|
187
|
+
end
|
|
188
|
+
exit 1
|
|
189
|
+
rescue Mysigner::ConnectionError => e
|
|
190
|
+
handle_connection_error(e, api_url)
|
|
191
|
+
exit 1
|
|
192
|
+
rescue => e
|
|
193
|
+
handle_unexpected_error(e, api_url)
|
|
194
|
+
exit 1
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
desc "onboard", "⭐ START HERE - Complete setup wizard for new users"
|
|
199
|
+
long_desc <<~DESC
|
|
200
|
+
Step-by-step guide to get started with My Signer CLI.
|
|
201
|
+
|
|
202
|
+
This command will:
|
|
203
|
+
1. Check if you have an account
|
|
204
|
+
2. Guide you through creating an organization
|
|
205
|
+
3. Help you generate an API token
|
|
206
|
+
4. Configure your CLI
|
|
207
|
+
DESC
|
|
208
|
+
def onboard
|
|
209
|
+
say "🚀 My Signer Setup Guide", :cyan
|
|
210
|
+
say "=" * 80, :cyan
|
|
211
|
+
say ""
|
|
212
|
+
say "Welcome! Let's get you set up with My Signer.", :bold
|
|
213
|
+
say ""
|
|
214
|
+
|
|
215
|
+
# Check if already configured
|
|
216
|
+
config = Config.new
|
|
217
|
+
if config.exists? && config.api_token && config.current_organization_id
|
|
218
|
+
say "✓ You're already logged in!", :green
|
|
219
|
+
say ""
|
|
220
|
+
say "Current configuration:", :cyan
|
|
221
|
+
say " Email: #{config.user_email}"
|
|
222
|
+
say " Organization ID: #{config.current_organization_id}"
|
|
223
|
+
say " API URL: #{config.api_url}"
|
|
224
|
+
say ""
|
|
225
|
+
|
|
226
|
+
# Check App Store Connect status
|
|
227
|
+
begin
|
|
228
|
+
client = Client.new(api_url: config.api_url, api_token: config.api_token)
|
|
229
|
+
org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
|
|
230
|
+
org_data = org_response[:data]
|
|
231
|
+
|
|
232
|
+
asc_configured = org_data['app_store_connect_configured'] || false
|
|
233
|
+
|
|
234
|
+
if !asc_configured
|
|
235
|
+
# Missing ASC credentials - offer to add them
|
|
236
|
+
say "⚠️ App Store Connect: Not configured", :yellow
|
|
237
|
+
say ""
|
|
238
|
+
say "What would you like to do?", :bold
|
|
239
|
+
say " 1. Set up App Store Connect credentials now"
|
|
240
|
+
say " 2. Check status with 'mysigner status'"
|
|
241
|
+
say " 3. Log out and start fresh"
|
|
242
|
+
say " 4. Exit"
|
|
243
|
+
say ""
|
|
244
|
+
|
|
245
|
+
choice = ask("Select (1-4):", limited_to: ['1', '2', '3', '4'])
|
|
246
|
+
say ""
|
|
247
|
+
|
|
248
|
+
case choice
|
|
249
|
+
when '1'
|
|
250
|
+
# Go directly to ASC setup
|
|
251
|
+
say "🚀 Setting up App Store Connect credentials...", :cyan
|
|
252
|
+
say ""
|
|
253
|
+
asc_configured = setup_app_store_connect_credentials(client, config, config.current_organization_id)
|
|
254
|
+
|
|
255
|
+
say ""
|
|
256
|
+
say "=" * 80, :green
|
|
257
|
+
if asc_configured
|
|
258
|
+
say "✓ App Store Connect configured successfully!", :green
|
|
259
|
+
else
|
|
260
|
+
say "⚠️ Setup incomplete", :yellow
|
|
261
|
+
say "Run 'mysigner onboard' again or use 'mysigner doctor'", :yellow
|
|
262
|
+
end
|
|
263
|
+
say "=" * 80, :green
|
|
264
|
+
return
|
|
265
|
+
when '2'
|
|
266
|
+
invoke :status
|
|
267
|
+
return
|
|
268
|
+
when '3'
|
|
269
|
+
say "Clearing configuration...", :yellow
|
|
270
|
+
say ""
|
|
271
|
+
# Continue with full onboarding
|
|
272
|
+
when '4'
|
|
273
|
+
say "No changes made.", :green
|
|
274
|
+
return
|
|
275
|
+
end
|
|
276
|
+
else
|
|
277
|
+
# Already fully configured
|
|
278
|
+
say "✓ App Store Connect: Configured", :green
|
|
279
|
+
if org_data['app_store_connect_team_id']
|
|
280
|
+
say " Team ID: #{org_data['app_store_connect_team_id']}"
|
|
281
|
+
end
|
|
282
|
+
say ""
|
|
283
|
+
say "You're all set! 🎉", :bold
|
|
284
|
+
say ""
|
|
285
|
+
say "What would you like to do?", :bold
|
|
286
|
+
say " 1. Check status"
|
|
287
|
+
say " 2. Switch to another organization"
|
|
288
|
+
say " 3. Log out and start fresh"
|
|
289
|
+
say " 4. Exit"
|
|
290
|
+
say ""
|
|
291
|
+
|
|
292
|
+
choice = ask("Select (1-4):", limited_to: ['1', '2', '3', '4'])
|
|
293
|
+
say ""
|
|
294
|
+
|
|
295
|
+
case choice
|
|
296
|
+
when '1'
|
|
297
|
+
invoke :status
|
|
298
|
+
return
|
|
299
|
+
when '2'
|
|
300
|
+
invoke :switch
|
|
301
|
+
return
|
|
302
|
+
when '3'
|
|
303
|
+
say "Clearing configuration...", :yellow
|
|
304
|
+
say ""
|
|
305
|
+
# Continue with full onboarding
|
|
306
|
+
when '4'
|
|
307
|
+
say "No changes made.", :green
|
|
308
|
+
return
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
rescue => e
|
|
312
|
+
say "⚠️ Could not check organization status: #{e.message}", :yellow
|
|
313
|
+
say ""
|
|
314
|
+
|
|
315
|
+
unless yes_with_default?("Do you want to re-configure from scratch?", :yellow)
|
|
316
|
+
say ""
|
|
317
|
+
say "Keeping existing configuration.", :green
|
|
318
|
+
say ""
|
|
319
|
+
say "💡 Tip: Use 'mysigner status' to check your setup", :cyan
|
|
320
|
+
say "💡 Tip: Use 'mysigner switch' to add another organization", :cyan
|
|
321
|
+
return
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
say ""
|
|
325
|
+
say "Clearing existing configuration...", :yellow
|
|
326
|
+
say ""
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Get API URL
|
|
331
|
+
api_url = prompt_api_url
|
|
332
|
+
say ""
|
|
333
|
+
|
|
334
|
+
# Step 1: Check if user has account
|
|
335
|
+
say "Step 1: Account Setup", :cyan
|
|
336
|
+
say "-" * 80
|
|
337
|
+
say ""
|
|
338
|
+
say "Do you have a My Signer account?", :bold
|
|
339
|
+
say " 1. Yes, I have an account"
|
|
340
|
+
say " 2. No, I need to sign up"
|
|
341
|
+
say ""
|
|
342
|
+
|
|
343
|
+
choice = ask("Select (1-2):", limited_to: ['1', '2'])
|
|
344
|
+
say ""
|
|
345
|
+
|
|
346
|
+
if choice == '2'
|
|
347
|
+
# Guide to signup
|
|
348
|
+
say "📝 Let's create your account:", :cyan
|
|
349
|
+
say ""
|
|
350
|
+
say "1. Open your browser and go to:", :bold
|
|
351
|
+
say " #{api_url}", :green
|
|
352
|
+
say ""
|
|
353
|
+
say "2. Click 'Sign Up' and create your account", :bold
|
|
354
|
+
say ""
|
|
355
|
+
say "3. Verify your email (check your inbox)", :bold
|
|
356
|
+
say ""
|
|
357
|
+
|
|
358
|
+
unless yes_with_default?("Have you created your account?", :green)
|
|
359
|
+
say ""
|
|
360
|
+
say "Come back and run 'mysigner onboard' when you're ready!", :yellow
|
|
361
|
+
return
|
|
362
|
+
end
|
|
363
|
+
say ""
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Step 2: Organization
|
|
367
|
+
say "Step 2: Organization Setup", :cyan
|
|
368
|
+
say "-" * 80
|
|
369
|
+
say ""
|
|
370
|
+
say "Do you have an organization?", :bold
|
|
371
|
+
say " 1. Yes, I have an organization"
|
|
372
|
+
say " 2. No, I need to create one"
|
|
373
|
+
say ""
|
|
374
|
+
|
|
375
|
+
choice = ask("Select (1-2):", limited_to: ['1', '2'])
|
|
376
|
+
say ""
|
|
377
|
+
|
|
378
|
+
if choice == '2'
|
|
379
|
+
# Guide to create org
|
|
380
|
+
say "🏢 Let's create your organization:", :cyan
|
|
381
|
+
say ""
|
|
382
|
+
say "1. Go to the dashboard:", :bold
|
|
383
|
+
say " #{api_url}", :green
|
|
384
|
+
say ""
|
|
385
|
+
say "2. Sign in with your account", :bold
|
|
386
|
+
say ""
|
|
387
|
+
say "3. Click 'Create Organization'", :bold
|
|
388
|
+
say ""
|
|
389
|
+
say "4. Enter your organization name (e.g., 'My Startup')", :bold
|
|
390
|
+
say ""
|
|
391
|
+
|
|
392
|
+
unless yes_with_default?("Have you created your organization?", :green)
|
|
393
|
+
say ""
|
|
394
|
+
say "Come back and run 'mysigner onboard' when you're ready!", :yellow
|
|
395
|
+
return
|
|
396
|
+
end
|
|
397
|
+
say ""
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Step 3: API Token
|
|
401
|
+
say "Step 3: Generate API Token", :cyan
|
|
402
|
+
say "-" * 80
|
|
403
|
+
say ""
|
|
404
|
+
say "Now let's generate your API token:", :bold
|
|
405
|
+
say ""
|
|
406
|
+
say "1. Go to API Tokens:", :bold
|
|
407
|
+
say " #{api_url}/organizations/YOUR_ORG_ID/api_tokens", :green
|
|
408
|
+
say ""
|
|
409
|
+
say " Or navigate: Dashboard → Your Organization → API Tokens", :cyan
|
|
410
|
+
say ""
|
|
411
|
+
say "2. Click 'Create Token'", :bold
|
|
412
|
+
say ""
|
|
413
|
+
say "3. Fill in the details:", :bold
|
|
414
|
+
say " • Name: 'CLI Access' (or anything you like)"
|
|
415
|
+
say " • Scopes: ✓ read ✓ write (minimum required)"
|
|
416
|
+
say " • Expiration: Choose 'Never' or '1 year'"
|
|
417
|
+
say ""
|
|
418
|
+
say "4. Click 'Create' and COPY the token", :bold
|
|
419
|
+
say " ⚠️ You'll only see it once!", :yellow
|
|
420
|
+
say ""
|
|
421
|
+
|
|
422
|
+
unless yes_with_default?("Have you generated and copied your token?", :green)
|
|
423
|
+
say ""
|
|
424
|
+
say "Come back and run 'mysigner onboard' when you have your token!", :yellow
|
|
425
|
+
return
|
|
426
|
+
end
|
|
427
|
+
say ""
|
|
428
|
+
|
|
429
|
+
# Step 4: Login
|
|
430
|
+
say "Step 4: Login to CLI", :cyan
|
|
431
|
+
say "-" * 80
|
|
432
|
+
say ""
|
|
433
|
+
say "Great! Now let's log you in.", :bold
|
|
434
|
+
say ""
|
|
435
|
+
|
|
436
|
+
# Get user email
|
|
437
|
+
user_email = prompt_for_email
|
|
438
|
+
say ""
|
|
439
|
+
|
|
440
|
+
api_token = ask("Paste your API Token:", echo: false)
|
|
441
|
+
say ""
|
|
442
|
+
|
|
443
|
+
if api_token.empty?
|
|
444
|
+
error "Token cannot be empty"
|
|
445
|
+
say "Run 'mysigner onboard' again when you have your token", :yellow
|
|
446
|
+
return
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
say "Validating token and email...", :yellow
|
|
450
|
+
|
|
451
|
+
begin
|
|
452
|
+
client = Client.new(api_url: api_url, api_token: api_token, user_email: user_email)
|
|
453
|
+
response = client.test_connection
|
|
454
|
+
|
|
455
|
+
unless response[:success]
|
|
456
|
+
error "Connection test failed"
|
|
457
|
+
return
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
response = client.get('/api/v1/organizations')
|
|
461
|
+
organizations = response[:data]['organizations']
|
|
462
|
+
|
|
463
|
+
if organizations.empty?
|
|
464
|
+
error "No organizations found"
|
|
465
|
+
say "Please check that your token is associated with an organization", :yellow
|
|
466
|
+
return
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
selected_org = organizations.first
|
|
470
|
+
org_id = selected_org['id']
|
|
471
|
+
|
|
472
|
+
# Get detailed org info
|
|
473
|
+
org_response = client.get("/api/v1/organizations/#{org_id}")
|
|
474
|
+
org_data = org_response[:data]
|
|
475
|
+
|
|
476
|
+
config = Config.new
|
|
477
|
+
config.api_url = api_url
|
|
478
|
+
config.user_email = user_email # Save the verified email
|
|
479
|
+
config.current_organization_id = org_id
|
|
480
|
+
config.save_token_for_org(org_id, org_data['name'], api_token)
|
|
481
|
+
config.save
|
|
482
|
+
|
|
483
|
+
# Step 5: App Store Connect Setup (Optional)
|
|
484
|
+
say ""
|
|
485
|
+
say "Step 5: App Store Connect Setup (optional but recommended)", :cyan
|
|
486
|
+
say "-" * 80
|
|
487
|
+
say ""
|
|
488
|
+
|
|
489
|
+
# Check if already configured
|
|
490
|
+
asc_configured = org_data['app_store_connect_configured'] || false
|
|
491
|
+
|
|
492
|
+
if asc_configured
|
|
493
|
+
say "✓ App Store Connect is already configured!", :green
|
|
494
|
+
say ""
|
|
495
|
+
say "Current setup:", :cyan
|
|
496
|
+
if org_data['app_store_connect_team_id']
|
|
497
|
+
say " • Team ID: #{org_data['app_store_connect_team_id']}"
|
|
498
|
+
end
|
|
499
|
+
say " • Status: Active"
|
|
500
|
+
say ""
|
|
501
|
+
say "You can manage credentials in the web dashboard:", :cyan
|
|
502
|
+
say " #{api_url}/organizations/#{org_id}", :green
|
|
503
|
+
say ""
|
|
504
|
+
say "💡 Tip: You can add multiple credentials (for different teams)", :cyan
|
|
505
|
+
say ""
|
|
506
|
+
else
|
|
507
|
+
say "To upload to TestFlight/App Store, we need your API credentials.", :bold
|
|
508
|
+
say ""
|
|
509
|
+
say "Do you want to set this up now?", :bold
|
|
510
|
+
say " 1. Yes, guide me through it (recommended)"
|
|
511
|
+
say " 2. Skip for now (you can do this later)"
|
|
512
|
+
say ""
|
|
513
|
+
|
|
514
|
+
asc_choice = ask("Select (1-2):", limited_to: ['1', '2'])
|
|
515
|
+
say ""
|
|
516
|
+
|
|
517
|
+
if asc_choice == '1'
|
|
518
|
+
asc_configured = setup_app_store_connect_credentials(client, config, org_id)
|
|
519
|
+
else
|
|
520
|
+
say "⏭️ Skipped App Store Connect setup", :yellow
|
|
521
|
+
say ""
|
|
522
|
+
say "You can set this up later by:", :cyan
|
|
523
|
+
say " • Running: mysigner doctor"
|
|
524
|
+
say " • Or via the web dashboard"
|
|
525
|
+
say ""
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
say ""
|
|
530
|
+
say "=" * 80, :green
|
|
531
|
+
say "🎉 Setup Complete!", :green
|
|
532
|
+
say "=" * 80, :green
|
|
533
|
+
say ""
|
|
534
|
+
say "You're all set up and ready to go!", :bold
|
|
535
|
+
say ""
|
|
536
|
+
say "User: #{user_email}", :cyan
|
|
537
|
+
say "Organization: #{org_data['name']} (ID: #{org_id})", :cyan
|
|
538
|
+
say "Config saved to: #{Config::CONFIG_FILE}", :cyan
|
|
539
|
+
|
|
540
|
+
# Show App Store Connect status
|
|
541
|
+
say ""
|
|
542
|
+
if asc_configured
|
|
543
|
+
say "✓ App Store Connect: Configured", :green
|
|
544
|
+
elsif defined?(asc_choice) && asc_choice == '1'
|
|
545
|
+
say "⚠️ App Store Connect:", :yellow
|
|
546
|
+
say " Setup was attempted but not completed", :yellow
|
|
547
|
+
say " Run 'mysigner doctor' to configure it", :yellow
|
|
548
|
+
else
|
|
549
|
+
say "⚠️ App Store Connect: Not configured", :yellow
|
|
550
|
+
say " Run 'mysigner doctor' to set it up", :yellow
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
say ""
|
|
554
|
+
say "🔒 Security Note:", :yellow
|
|
555
|
+
say " Your token is organization-specific. Use 'mysigner switch'", :yellow
|
|
556
|
+
say " to add tokens for other organizations.", :yellow
|
|
557
|
+
say ""
|
|
558
|
+
say "🚀 Try your first ship:", :bold
|
|
559
|
+
say ""
|
|
560
|
+
say " cd your-ios-project"
|
|
561
|
+
say " mysigner ship testflight"
|
|
562
|
+
say ""
|
|
563
|
+
say "💡 Tips:", :cyan
|
|
564
|
+
say " • Run 'mysigner doctor' to check your environment"
|
|
565
|
+
say " • Run 'mysigner --help' to see all commands"
|
|
566
|
+
say " • Run 'mysigner status' to verify your setup"
|
|
567
|
+
say ""
|
|
568
|
+
|
|
569
|
+
rescue Mysigner::UnauthorizedError => e
|
|
570
|
+
error "Authentication failed"
|
|
571
|
+
say ""
|
|
572
|
+
|
|
573
|
+
# Check if it's an email validation error
|
|
574
|
+
if e.message.include?("doesn't belong to") || e.message.include?("use your own token")
|
|
575
|
+
say "⚠️ Token Email Mismatch", :yellow
|
|
576
|
+
say ""
|
|
577
|
+
say "The token you provided doesn't belong to #{user_email}.", :yellow
|
|
578
|
+
say ""
|
|
579
|
+
say "Please make sure you:", :yellow
|
|
580
|
+
say " 1. Are logged in as #{user_email} on the web dashboard", :yellow
|
|
581
|
+
say " 2. Generate the token while logged in as #{user_email}", :yellow
|
|
582
|
+
say " 3. Enter the correct email address", :yellow
|
|
583
|
+
else
|
|
584
|
+
say "The token you entered is invalid. Please:", :yellow
|
|
585
|
+
say " 1. Check you copied the entire token"
|
|
586
|
+
say " 2. Make sure the token hasn't been revoked"
|
|
587
|
+
say " 3. Generate a new token if needed"
|
|
588
|
+
end
|
|
589
|
+
say ""
|
|
590
|
+
say "Run 'mysigner onboard' to try again", :yellow
|
|
591
|
+
rescue => e
|
|
592
|
+
error "Setup failed: #{e.message}"
|
|
593
|
+
say ""
|
|
594
|
+
say "Run 'mysigner onboard' to try again", :yellow
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
desc "logout", "Log out and clear stored credentials"
|
|
599
|
+
def logout
|
|
600
|
+
config = Config.new
|
|
601
|
+
|
|
602
|
+
unless config.exists?
|
|
603
|
+
say "No stored credentials found", :yellow
|
|
604
|
+
return
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
if yes?("Are you sure you want to logout? (y/n)")
|
|
608
|
+
config.clear
|
|
609
|
+
say "✓ Successfully logged out", :green
|
|
610
|
+
say "Config file removed: #{Config::CONFIG_FILE}", :green
|
|
611
|
+
else
|
|
612
|
+
say "Logout cancelled", :yellow
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
desc "status", "Check connection, credentials, and App Store Connect setup"
|
|
617
|
+
def status
|
|
618
|
+
config = Config.new
|
|
619
|
+
|
|
620
|
+
unless config.exists?
|
|
621
|
+
error "Not logged in. Run 'mysigner login' first."
|
|
622
|
+
exit 1
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
config.load
|
|
626
|
+
|
|
627
|
+
say "📊 My Signer Status", :cyan
|
|
628
|
+
say ""
|
|
629
|
+
say "Configuration:", :bold
|
|
630
|
+
say " API URL: #{config.api_url}"
|
|
631
|
+
say " User: #{config.user_email || '(unknown)'}"
|
|
632
|
+
say " Encryption: #{config.encrypted_config? ? '✓ Enabled' : '✗ Disabled'}"
|
|
633
|
+
say ""
|
|
634
|
+
|
|
635
|
+
# Show current organization
|
|
636
|
+
if config.current_organization_id
|
|
637
|
+
say "Current Organization:", :bold
|
|
638
|
+
say " Name: #{config.org_name || '(unknown)'}"
|
|
639
|
+
say " ID: #{config.current_organization_id}"
|
|
640
|
+
say " Token: #{config.display[:current_token]}"
|
|
641
|
+
say ""
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# Show all saved organizations
|
|
645
|
+
if config.organization_ids.length > 1
|
|
646
|
+
say "Saved Organizations: (#{config.organization_ids.length})", :bold
|
|
647
|
+
config.organization_ids.each do |org_id|
|
|
648
|
+
current_marker = org_id == config.current_organization_id ? " (current)" : ""
|
|
649
|
+
org_name = config.org_name(org_id) || "Unknown"
|
|
650
|
+
say " • #{org_name}#{current_marker} (ID: #{org_id})"
|
|
651
|
+
end
|
|
652
|
+
say ""
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# Test connection
|
|
656
|
+
say "Connection:", :bold
|
|
657
|
+
|
|
658
|
+
begin
|
|
659
|
+
client = Client.new(api_url: config.api_url, api_token: config.api_token)
|
|
660
|
+
client.test_connection
|
|
661
|
+
|
|
662
|
+
say " Status: ✓ Connected", :green
|
|
663
|
+
|
|
664
|
+
# Get organization details
|
|
665
|
+
if config.current_organization_id
|
|
666
|
+
org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
|
|
667
|
+
org = org_response[:data]
|
|
668
|
+
|
|
669
|
+
say " Role: #{org['role'] || 'viewer'}"
|
|
670
|
+
say " Members: #{org['member_count'] || 0}"
|
|
671
|
+
say ""
|
|
672
|
+
|
|
673
|
+
# Show App Store Connect status
|
|
674
|
+
say "App Store Connect:", :bold
|
|
675
|
+
if org['app_store_connect_configured']
|
|
676
|
+
say " ✓ Configured", :green
|
|
677
|
+
if org['app_store_connect_team_id']
|
|
678
|
+
say " Team ID: #{org['app_store_connect_team_id']}"
|
|
679
|
+
end
|
|
680
|
+
else
|
|
681
|
+
say " ✗ Not configured", :yellow
|
|
682
|
+
say " Run 'mysigner doctor' to set it up"
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
rescue Mysigner::UnauthorizedError
|
|
686
|
+
say " Status: ✗ Unauthorized (invalid token)", :red
|
|
687
|
+
exit 1
|
|
688
|
+
rescue Mysigner::ConnectionError => e
|
|
689
|
+
say " Status: ✗ Connection failed", :red
|
|
690
|
+
say " Error: #{e.message}", :red
|
|
691
|
+
exit 1
|
|
692
|
+
rescue => e
|
|
693
|
+
say " Status: ✗ Error", :red
|
|
694
|
+
say " Error: #{e.message}", :red
|
|
695
|
+
exit 1
|
|
696
|
+
end
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
desc "orgs", "List all organizations you're a member of"
|
|
700
|
+
def orgs
|
|
701
|
+
config = load_config
|
|
702
|
+
client = create_client(config)
|
|
703
|
+
|
|
704
|
+
say "📋 Organizations", :cyan
|
|
705
|
+
say ""
|
|
706
|
+
|
|
707
|
+
begin
|
|
708
|
+
# Fetch ALL organizations the user is a member of (not restricted by token's org)
|
|
709
|
+
response = client.get('/api/v1/user/organizations')
|
|
710
|
+
api_organizations = response[:data]['organizations']
|
|
711
|
+
|
|
712
|
+
# Get all org IDs from both config and API
|
|
713
|
+
all_org_ids = (config.organization_ids + api_organizations.map { |o| o['id'] }).uniq
|
|
714
|
+
|
|
715
|
+
if all_org_ids.empty?
|
|
716
|
+
say "No organizations found", :yellow
|
|
717
|
+
return
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
all_org_ids.each do |org_id|
|
|
721
|
+
has_token = config.has_token_for_org?(org_id)
|
|
722
|
+
is_current = org_id == config.current_organization_id
|
|
723
|
+
|
|
724
|
+
# Get org details
|
|
725
|
+
org_name = config.org_name(org_id)
|
|
726
|
+
api_org = api_organizations.find { |o| o['id'] == org_id }
|
|
727
|
+
org_name = api_org['name'] if api_org && (org_name.nil? || org_name == 'Unknown')
|
|
728
|
+
|
|
729
|
+
current_marker = is_current ? " (current)" : ""
|
|
730
|
+
token_status = has_token ? "✓" : "⚠️"
|
|
731
|
+
|
|
732
|
+
say " #{token_status} #{org_name}#{current_marker}", :green
|
|
733
|
+
|
|
734
|
+
if api_org
|
|
735
|
+
role = api_org['role'] || 'viewer'
|
|
736
|
+
say " ID: #{org_id} | Role: #{role} | Members: #{api_org['member_count'] || 0}"
|
|
737
|
+
else
|
|
738
|
+
say " ID: #{org_id} | #{has_token ? 'Token saved' : 'Need token to access'}"
|
|
739
|
+
end
|
|
740
|
+
say ""
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
say "Total: #{all_org_ids.length} organization(s)", :white
|
|
744
|
+
say ""
|
|
745
|
+
say "Legend: ✓ = Has token | ⚠️ = Need token", :white
|
|
746
|
+
say ""
|
|
747
|
+
say "💡 Tip: Use 'mysigner switch' to change organizations", :yellow
|
|
748
|
+
rescue Mysigner::ClientError => e
|
|
749
|
+
error "Failed to fetch organizations: #{e.message}"
|
|
750
|
+
exit 1
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
desc "switch", "Switch between organizations (for multi-org users)"
|
|
755
|
+
long_desc <<~DESC
|
|
756
|
+
Switch to a different organization.
|
|
757
|
+
|
|
758
|
+
With organization-specific tokens, you'll need a token for each
|
|
759
|
+
organization you want to access. This command will:
|
|
760
|
+
- Show all organizations you're a member of
|
|
761
|
+
- Indicate which ones you have tokens for (✓) or need tokens (⚠️)
|
|
762
|
+
- Prompt for a token if switching to an org without one
|
|
763
|
+
- Validate the token belongs to the target organization
|
|
764
|
+
- Update your configuration
|
|
765
|
+
|
|
766
|
+
Note: You need to be the same user in all organizations. Tokens
|
|
767
|
+
from different user accounts will be rejected.
|
|
768
|
+
DESC
|
|
769
|
+
def switch
|
|
770
|
+
config = load_config
|
|
771
|
+
client = create_client(config)
|
|
772
|
+
|
|
773
|
+
say "🔄 Switch Organization", :cyan
|
|
774
|
+
say ""
|
|
775
|
+
|
|
776
|
+
begin
|
|
777
|
+
# Get current org details
|
|
778
|
+
current_org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
|
|
779
|
+
current_org = current_org_response[:data]
|
|
780
|
+
|
|
781
|
+
say "Current organization:", :yellow
|
|
782
|
+
say " #{current_org['name']} (ID: #{config.current_organization_id})", :green
|
|
783
|
+
say ""
|
|
784
|
+
|
|
785
|
+
# Fetch ALL organizations the user is a member of (not restricted by token's org)
|
|
786
|
+
response = client.get('/api/v1/user/organizations')
|
|
787
|
+
api_organizations = response[:data]['organizations']
|
|
788
|
+
|
|
789
|
+
# Build comprehensive list: stored orgs + API orgs
|
|
790
|
+
all_org_ids = (config.organization_ids + api_organizations.map { |o| o['id'] }).uniq
|
|
791
|
+
|
|
792
|
+
if all_org_ids.length < 2
|
|
793
|
+
say "You only have access to one organization.", :yellow
|
|
794
|
+
say "Nothing to switch to!", :yellow
|
|
795
|
+
say ""
|
|
796
|
+
say "💡 Tip: If you're a member of other organizations, you'll need", :cyan
|
|
797
|
+
say " to generate tokens for them first (in the web dashboard).", :cyan
|
|
798
|
+
return
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
# Show available organizations
|
|
802
|
+
say "Available organizations:", :cyan
|
|
803
|
+
say ""
|
|
804
|
+
organizations_list = []
|
|
805
|
+
|
|
806
|
+
all_org_ids.each_with_index do |org_id, index|
|
|
807
|
+
has_token = config.has_token_for_org?(org_id)
|
|
808
|
+
is_current = org_id == config.current_organization_id
|
|
809
|
+
|
|
810
|
+
# Get org name from config or API
|
|
811
|
+
org_name = config.org_name(org_id)
|
|
812
|
+
if org_name.nil? || org_name == 'Unknown'
|
|
813
|
+
# Try to fetch from API if we have a token
|
|
814
|
+
api_org = api_organizations.find { |o| o['id'] == org_id }
|
|
815
|
+
org_name = api_org['name'] if api_org
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
status = has_token ? "✓" : "⚠️ "
|
|
819
|
+
current_marker = is_current ? " (current)" : ""
|
|
820
|
+
|
|
821
|
+
say " #{index + 1}. #{status} #{org_name}#{current_marker}"
|
|
822
|
+
say " ID: #{org_id} | #{has_token ? 'Token saved' : 'Need token'}", :white
|
|
823
|
+
|
|
824
|
+
organizations_list << { id: org_id, name: org_name, has_token: has_token }
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
say ""
|
|
828
|
+
say "Legend: ✓ = Has token | ⚠️ = Needs token", :white
|
|
829
|
+
say ""
|
|
830
|
+
|
|
831
|
+
# Let user select
|
|
832
|
+
org_index = ask("Select organization (1-#{organizations_list.length}, or 'q' to cancel):")
|
|
833
|
+
|
|
834
|
+
if org_index.downcase == 'q'
|
|
835
|
+
say "Cancelled", :yellow
|
|
836
|
+
return
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
unless org_index.match(/^\d+$/) && org_index.to_i.between?(1, organizations_list.length)
|
|
840
|
+
error "Invalid selection"
|
|
841
|
+
return
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
selected_org = organizations_list[org_index.to_i - 1]
|
|
845
|
+
|
|
846
|
+
if selected_org[:id] == config.current_organization_id
|
|
847
|
+
say ""
|
|
848
|
+
say "Already using this organization!", :yellow
|
|
849
|
+
return
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
# Check if we have a token for this org
|
|
853
|
+
unless selected_org[:has_token]
|
|
854
|
+
say ""
|
|
855
|
+
say "⚠️ You don't have a token for '#{selected_org[:name]}' yet.", :yellow
|
|
856
|
+
say ""
|
|
857
|
+
say "To switch to this organization:", :cyan
|
|
858
|
+
say " 1. Go to: #{config.api_url}/organizations/#{selected_org[:id]}/api_tokens"
|
|
859
|
+
say " 2. Generate a new API token"
|
|
860
|
+
say " 3. Paste it below"
|
|
861
|
+
say ""
|
|
862
|
+
|
|
863
|
+
new_token = ask("Paste API token for '#{selected_org[:name]}' (or 'q' to cancel):", echo: false)
|
|
864
|
+
say ""
|
|
865
|
+
|
|
866
|
+
if new_token.downcase == 'q' || new_token.empty?
|
|
867
|
+
say "Cancelled", :yellow
|
|
868
|
+
return
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# Validate the new token (with email validation)
|
|
872
|
+
say "Validating token...", :yellow
|
|
873
|
+
|
|
874
|
+
begin
|
|
875
|
+
# Use stored email from config for validation
|
|
876
|
+
temp_client = Client.new(
|
|
877
|
+
api_url: config.api_url,
|
|
878
|
+
api_token: new_token,
|
|
879
|
+
user_email: config.user_email
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
# Try to fetch the target organization with the new token
|
|
883
|
+
validation_response = temp_client.get("/api/v1/organizations/#{selected_org[:id]}")
|
|
884
|
+
token_org_data = validation_response[:data]
|
|
885
|
+
|
|
886
|
+
# Check if token_organization_id matches (new backend feature)
|
|
887
|
+
if token_org_data['token_organization_id'] && token_org_data['token_organization_id'] != selected_org[:id]
|
|
888
|
+
error "This token belongs to a different organization!"
|
|
889
|
+
say ""
|
|
890
|
+
say "The token you provided is for organization ID #{token_org_data['token_organization_id']},", :yellow
|
|
891
|
+
say "but you're trying to access organization ID #{selected_org[:id]}.", :yellow
|
|
892
|
+
say ""
|
|
893
|
+
say "Please generate a token from the correct organization.", :yellow
|
|
894
|
+
exit 1
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
say "✓ Token validated successfully", :green
|
|
898
|
+
|
|
899
|
+
# Save the token
|
|
900
|
+
config.save_token_for_org(selected_org[:id], selected_org[:name], new_token)
|
|
901
|
+
|
|
902
|
+
rescue Mysigner::UnauthorizedError => e
|
|
903
|
+
error "Token validation failed"
|
|
904
|
+
say ""
|
|
905
|
+
|
|
906
|
+
# Check if it's an email validation error
|
|
907
|
+
if e.message.include?("doesn't belong to") || e.message.include?("use your own token")
|
|
908
|
+
say "⚠️ This token doesn't belong to #{config.user_email}!", :yellow
|
|
909
|
+
say ""
|
|
910
|
+
say "You can only use tokens from your own account (#{config.user_email}).", :yellow
|
|
911
|
+
say "Please generate a token while logged in as #{config.user_email} on the web.", :yellow
|
|
912
|
+
else
|
|
913
|
+
say "The token you provided is not valid.", :yellow
|
|
914
|
+
end
|
|
915
|
+
exit 1
|
|
916
|
+
rescue => e
|
|
917
|
+
error "Token validation failed: #{e.message}"
|
|
918
|
+
exit 1
|
|
919
|
+
end
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
# Update config to switch to the new org
|
|
923
|
+
config.current_organization_id = selected_org[:id]
|
|
924
|
+
config.save
|
|
925
|
+
|
|
926
|
+
say ""
|
|
927
|
+
say "✓ Successfully switched to: #{selected_org[:name]}", :green
|
|
928
|
+
say ""
|
|
929
|
+
say "💡 Run 'mysigner status' to verify your new configuration", :cyan
|
|
930
|
+
|
|
931
|
+
rescue Mysigner::ClientError => e
|
|
932
|
+
error "Failed to switch organization: #{e.message}"
|
|
933
|
+
exit 1
|
|
934
|
+
end
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
desc "config", "Show current CLI configuration (API URL, tokens, org)"
|
|
938
|
+
def config
|
|
939
|
+
config = Config.new
|
|
940
|
+
|
|
941
|
+
unless config.exists?
|
|
942
|
+
error "No configuration found. Run 'mysigner login' first."
|
|
943
|
+
exit 1
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
config.load
|
|
947
|
+
|
|
948
|
+
say "⚙️ Configuration", :cyan
|
|
949
|
+
say ""
|
|
950
|
+
config.display.each do |key, value|
|
|
951
|
+
say " #{key.to_s.ljust(20)}: #{value}"
|
|
952
|
+
end
|
|
953
|
+
say ""
|
|
954
|
+
say "Config file: #{Config::CONFIG_FILE}"
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
no_commands do
|
|
958
|
+
# Helper method for yes/no prompts with Enter defaulting to yes
|
|
959
|
+
def yes_with_default?(statement, color = nil)
|
|
960
|
+
response = ask("#{statement} [Y/n]", color).to_s.strip.downcase
|
|
961
|
+
response.empty? || response == 'y' || response == 'yes'
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
# Helper method for App Store Connect credential setup
|
|
965
|
+
# Returns true if successfully configured, false otherwise
|
|
966
|
+
def setup_app_store_connect_credentials(client, config, org_id)
|
|
967
|
+
say "📱 App Store Connect API Key Setup", :cyan
|
|
968
|
+
say ""
|
|
969
|
+
say "Let's set up your App Store Connect credentials.", :bold
|
|
970
|
+
say ""
|
|
971
|
+
say "Step 1: Create an API Key (if you don't have one)", :bold
|
|
972
|
+
say ""
|
|
973
|
+
say "1. Go to:", :cyan
|
|
974
|
+
say " https://appstoreconnect.apple.com/access/api", :green
|
|
975
|
+
say ""
|
|
976
|
+
say "2. Click the '+' button to create a new key", :cyan
|
|
977
|
+
say ""
|
|
978
|
+
say "3. Select access:", :cyan
|
|
979
|
+
say " • App Manager (for uploading builds)"
|
|
980
|
+
say " • Or Admin (full access)"
|
|
981
|
+
say ""
|
|
982
|
+
say "4. Download the .p8 file", :cyan
|
|
983
|
+
say " ⚠️ Save it securely - you can only download it once!", :yellow
|
|
984
|
+
say ""
|
|
985
|
+
|
|
986
|
+
unless yes_with_default?("Have you created and downloaded your API key?", :green)
|
|
987
|
+
say ""
|
|
988
|
+
say "⏭️ You can set this up later with:", :yellow
|
|
989
|
+
say " • mysigner doctor", :cyan
|
|
990
|
+
say " • Or via the web dashboard", :cyan
|
|
991
|
+
return false
|
|
992
|
+
end
|
|
993
|
+
say ""
|
|
994
|
+
|
|
995
|
+
# Prompt for .p8 file path with retry
|
|
996
|
+
max_retries = 3
|
|
997
|
+
attempts = 0
|
|
998
|
+
p8_path = nil
|
|
999
|
+
private_key = nil
|
|
1000
|
+
|
|
1001
|
+
while attempts < max_retries
|
|
1002
|
+
say "Step 2: Locate your .p8 file", :bold
|
|
1003
|
+
say ""
|
|
1004
|
+
say "💡 Tip: You can drag & drop the file into terminal to get the path", :cyan
|
|
1005
|
+
say ""
|
|
1006
|
+
p8_path = ask("Enter the path to your .p8 file:").strip.gsub(/^['"]|['"]$/, '') # Remove quotes
|
|
1007
|
+
say ""
|
|
1008
|
+
|
|
1009
|
+
# Expand ~ to home directory
|
|
1010
|
+
p8_path = File.expand_path(p8_path)
|
|
1011
|
+
|
|
1012
|
+
if File.exist?(p8_path)
|
|
1013
|
+
# Read private key
|
|
1014
|
+
begin
|
|
1015
|
+
private_key = File.read(p8_path).strip
|
|
1016
|
+
|
|
1017
|
+
# Validate it looks like a private key
|
|
1018
|
+
unless private_key.include?("BEGIN PRIVATE KEY") || private_key.include?("BEGIN EC PRIVATE KEY")
|
|
1019
|
+
error "This doesn't look like a valid .p8 private key file"
|
|
1020
|
+
attempts += 1
|
|
1021
|
+
next
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
break # Success!
|
|
1025
|
+
rescue => e
|
|
1026
|
+
error "Failed to read file: #{e.message}"
|
|
1027
|
+
attempts += 1
|
|
1028
|
+
next
|
|
1029
|
+
end
|
|
1030
|
+
else
|
|
1031
|
+
error "File not found: #{p8_path}"
|
|
1032
|
+
attempts += 1
|
|
1033
|
+
|
|
1034
|
+
if attempts < max_retries
|
|
1035
|
+
say ""
|
|
1036
|
+
say "Please try again (attempt #{attempts + 1}/#{max_retries})", :yellow
|
|
1037
|
+
say ""
|
|
1038
|
+
end
|
|
1039
|
+
end
|
|
1040
|
+
end
|
|
1041
|
+
|
|
1042
|
+
unless private_key
|
|
1043
|
+
say ""
|
|
1044
|
+
error "Could not read .p8 file after #{max_retries} attempts"
|
|
1045
|
+
say "Setup skipped. Run 'mysigner doctor' to try again.", :yellow
|
|
1046
|
+
return false
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
# Auto-extract Key ID from filename (e.g., AuthKey_ABC123.p8 → ABC123)
|
|
1050
|
+
filename = File.basename(p8_path)
|
|
1051
|
+
key_id = nil
|
|
1052
|
+
if filename =~ /AuthKey_([A-Z0-9]+)\.p8/i
|
|
1053
|
+
key_id = $1
|
|
1054
|
+
say "✓ Auto-detected Key ID: #{key_id}", :green
|
|
1055
|
+
say ""
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
# Prompt for Key ID if not auto-detected
|
|
1059
|
+
unless key_id
|
|
1060
|
+
say "Could not auto-detect Key ID from filename.", :yellow
|
|
1061
|
+
say ""
|
|
1062
|
+
say "Find your Key ID in App Store Connect:", :cyan
|
|
1063
|
+
say " https://appstoreconnect.apple.com/access/api", :green
|
|
1064
|
+
say ""
|
|
1065
|
+
key_id = ask("Enter your Key ID (e.g., ABC12345):").strip
|
|
1066
|
+
say ""
|
|
1067
|
+
|
|
1068
|
+
if key_id.empty?
|
|
1069
|
+
error "Key ID cannot be empty"
|
|
1070
|
+
say "Setup skipped. Run 'mysigner doctor' to try again.", :yellow
|
|
1071
|
+
return false
|
|
1072
|
+
end
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
# Prompt for Issuer ID
|
|
1076
|
+
say "Step 3: Find your Issuer ID", :bold
|
|
1077
|
+
say ""
|
|
1078
|
+
say "Find it in App Store Connect (top right of Keys page):", :cyan
|
|
1079
|
+
say " https://appstoreconnect.apple.com/access/api", :green
|
|
1080
|
+
say ""
|
|
1081
|
+
issuer_id = ask("Enter your Issuer ID (UUID format):").strip
|
|
1082
|
+
say ""
|
|
1083
|
+
|
|
1084
|
+
if issuer_id.empty?
|
|
1085
|
+
error "Issuer ID cannot be empty"
|
|
1086
|
+
say "Setup skipped. Run 'mysigner doctor' to try again.", :yellow
|
|
1087
|
+
return false
|
|
1088
|
+
end
|
|
1089
|
+
|
|
1090
|
+
# Basic UUID format validation
|
|
1091
|
+
unless issuer_id.match?(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
|
|
1092
|
+
say "⚠️ Warning: Issuer ID doesn't look like a UUID format", :yellow
|
|
1093
|
+
say " Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", :yellow
|
|
1094
|
+
say ""
|
|
1095
|
+
unless yes_with_default?("Continue anyway?", :yellow)
|
|
1096
|
+
say "Setup skipped. Run 'mysigner doctor' to try again.", :yellow
|
|
1097
|
+
return false
|
|
1098
|
+
end
|
|
1099
|
+
say ""
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
# Prompt for credential name
|
|
1103
|
+
say "Step 4: Name this credential", :bold
|
|
1104
|
+
say ""
|
|
1105
|
+
say "Choose a name to identify this API key (e.g., 'Production Key', 'Team A Key')", :cyan
|
|
1106
|
+
say "Default: 'CLI Setup' - just press Enter to use it", :cyan
|
|
1107
|
+
say ""
|
|
1108
|
+
|
|
1109
|
+
credential_name = nil
|
|
1110
|
+
while credential_name.nil? || credential_name.empty?
|
|
1111
|
+
name_input = ask("Credential name:").strip
|
|
1112
|
+
credential_name = name_input.empty? ? "CLI Setup" : name_input
|
|
1113
|
+
|
|
1114
|
+
if credential_name.empty?
|
|
1115
|
+
error "Name cannot be empty"
|
|
1116
|
+
say ""
|
|
1117
|
+
end
|
|
1118
|
+
end
|
|
1119
|
+
say ""
|
|
1120
|
+
say "→ Using name: '#{credential_name}'", :cyan
|
|
1121
|
+
say ""
|
|
1122
|
+
|
|
1123
|
+
# Validate and upload
|
|
1124
|
+
say "Step 5: Validating credentials with Apple...", :bold
|
|
1125
|
+
say ""
|
|
1126
|
+
say "This may take a few seconds...", :yellow
|
|
1127
|
+
say ""
|
|
1128
|
+
|
|
1129
|
+
begin
|
|
1130
|
+
response = client.post("/api/v1/organizations/#{org_id}/app_store_connect_credentials",
|
|
1131
|
+
body: {
|
|
1132
|
+
app_store_connect_credential: {
|
|
1133
|
+
name: credential_name,
|
|
1134
|
+
key_id: key_id,
|
|
1135
|
+
issuer_id: issuer_id,
|
|
1136
|
+
private_key: private_key
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
if response[:success]
|
|
1142
|
+
data = response[:data]
|
|
1143
|
+
team_id = data['team_id']
|
|
1144
|
+
|
|
1145
|
+
say "✓ Credentials validated successfully!", :green
|
|
1146
|
+
say ""
|
|
1147
|
+
say "Details:", :cyan
|
|
1148
|
+
say " • Name: #{credential_name}"
|
|
1149
|
+
say " • Key ID: #{key_id}"
|
|
1150
|
+
say " • Issuer ID: #{issuer_id}"
|
|
1151
|
+
if team_id
|
|
1152
|
+
say " • Team ID: #{team_id}"
|
|
1153
|
+
else
|
|
1154
|
+
say " • Team ID: (will be extracted after first sync)"
|
|
1155
|
+
end
|
|
1156
|
+
say " • Status: Active ✓"
|
|
1157
|
+
say ""
|
|
1158
|
+
say "🎉 App Store Connect is now configured!", :green
|
|
1159
|
+
say ""
|
|
1160
|
+
return true # Success!
|
|
1161
|
+
else
|
|
1162
|
+
error "Validation failed"
|
|
1163
|
+
say "Setup skipped. Run 'mysigner doctor' to try again.", :yellow
|
|
1164
|
+
return false
|
|
1165
|
+
end
|
|
1166
|
+
rescue Mysigner::ClientError => e
|
|
1167
|
+
error_msg = e.message
|
|
1168
|
+
|
|
1169
|
+
# Check for duplicate name error
|
|
1170
|
+
if error_msg.include?("Name has already been taken") || error_msg.include?("validation_failed")
|
|
1171
|
+
error "A credential with the name '#{credential_name}' already exists"
|
|
1172
|
+
say ""
|
|
1173
|
+
say "Please choose a different name and try again.", :yellow
|
|
1174
|
+
say "Or manage credentials via the web dashboard:", :cyan
|
|
1175
|
+
say " #{client.api_url}/organizations/#{org_id}", :green
|
|
1176
|
+
else
|
|
1177
|
+
error "Failed to configure credentials: #{error_msg}"
|
|
1178
|
+
say ""
|
|
1179
|
+
say "Common issues:", :yellow
|
|
1180
|
+
say " • Invalid Key ID or Issuer ID"
|
|
1181
|
+
say " • Incorrect .p8 file content"
|
|
1182
|
+
say " • API key doesn't have proper permissions"
|
|
1183
|
+
say " • API key may be revoked or expired"
|
|
1184
|
+
end
|
|
1185
|
+
|
|
1186
|
+
say ""
|
|
1187
|
+
say "Setup skipped. Run 'mysigner doctor' to try again.", :yellow
|
|
1188
|
+
return false
|
|
1189
|
+
rescue => e
|
|
1190
|
+
error "Unexpected error: #{e.message}"
|
|
1191
|
+
say "Setup skipped. Run 'mysigner doctor' to try again.", :yellow
|
|
1192
|
+
return false
|
|
1193
|
+
end
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
# Setup Google Play credentials
|
|
1197
|
+
def setup_google_play_credentials(client, config, org_id)
|
|
1198
|
+
say "🤖 Google Play Service Account Setup", :cyan
|
|
1199
|
+
say ""
|
|
1200
|
+
say "Let's set up your Google Play credentials.", :bold
|
|
1201
|
+
say ""
|
|
1202
|
+
say "Step 1: Create a Service Account (if you don't have one)", :bold
|
|
1203
|
+
say ""
|
|
1204
|
+
say "1. Go to Google Play Console:", :cyan
|
|
1205
|
+
say " https://play.google.com/console", :green
|
|
1206
|
+
say ""
|
|
1207
|
+
say "2. Navigate to: Settings → API access", :cyan
|
|
1208
|
+
say ""
|
|
1209
|
+
say "3. Click 'Create new service account' or 'Link existing service account'", :cyan
|
|
1210
|
+
say ""
|
|
1211
|
+
say "4. In Google Cloud Console, create a Service Account with:", :cyan
|
|
1212
|
+
say " • Name: 'My Signer CLI' (or anything)"
|
|
1213
|
+
say " • Role: Editor or Admin"
|
|
1214
|
+
say ""
|
|
1215
|
+
say "5. Create a JSON key for the service account", :cyan
|
|
1216
|
+
say " • Click on the service account → Keys → Add Key → JSON"
|
|
1217
|
+
say " • Download the JSON file"
|
|
1218
|
+
say ""
|
|
1219
|
+
say "6. Back in Play Console, grant the service account access:", :cyan
|
|
1220
|
+
say " • Click 'Done' in the modal"
|
|
1221
|
+
say " • Click on the service account"
|
|
1222
|
+
say " • Set permissions: Release apps, Manage production releases"
|
|
1223
|
+
say ""
|
|
1224
|
+
|
|
1225
|
+
unless yes_with_default?("Have you created and downloaded your service account JSON?", :green)
|
|
1226
|
+
say ""
|
|
1227
|
+
say "⏭️ You can set this up later with:", :yellow
|
|
1228
|
+
say " • mysigner doctor (will prompt for setup)", :cyan
|
|
1229
|
+
say " • Or via the web dashboard", :cyan
|
|
1230
|
+
return false
|
|
1231
|
+
end
|
|
1232
|
+
say ""
|
|
1233
|
+
|
|
1234
|
+
# Prompt for JSON file path with retry
|
|
1235
|
+
max_retries = 3
|
|
1236
|
+
attempts = 0
|
|
1237
|
+
json_path = nil
|
|
1238
|
+
service_account_json = nil
|
|
1239
|
+
|
|
1240
|
+
while attempts < max_retries
|
|
1241
|
+
say "Step 2: Locate your service account JSON file", :bold
|
|
1242
|
+
say ""
|
|
1243
|
+
say "💡 Tip: You can drag & drop the file into terminal to get the path", :cyan
|
|
1244
|
+
say ""
|
|
1245
|
+
json_path = ask("Enter the path to your service account JSON file:").strip.gsub(/^['"]|['"]$/, '')
|
|
1246
|
+
say ""
|
|
1247
|
+
|
|
1248
|
+
# Expand ~ to home directory
|
|
1249
|
+
json_path = File.expand_path(json_path)
|
|
1250
|
+
|
|
1251
|
+
if File.exist?(json_path)
|
|
1252
|
+
begin
|
|
1253
|
+
service_account_json = File.read(json_path).strip
|
|
1254
|
+
|
|
1255
|
+
# Validate it looks like a service account JSON
|
|
1256
|
+
parsed = JSON.parse(service_account_json)
|
|
1257
|
+
unless parsed['type'] == 'service_account' && parsed['client_email'] && parsed['private_key']
|
|
1258
|
+
error "This doesn't look like a valid service account JSON file"
|
|
1259
|
+
say "Expected: type: 'service_account', client_email, and private_key fields", :yellow
|
|
1260
|
+
attempts += 1
|
|
1261
|
+
next
|
|
1262
|
+
end
|
|
1263
|
+
|
|
1264
|
+
say "✓ Valid service account JSON detected", :green
|
|
1265
|
+
say " Email: #{parsed['client_email']}", :cyan
|
|
1266
|
+
say ""
|
|
1267
|
+
break # Success!
|
|
1268
|
+
rescue JSON::ParserError => e
|
|
1269
|
+
error "Invalid JSON file: #{e.message}"
|
|
1270
|
+
attempts += 1
|
|
1271
|
+
next
|
|
1272
|
+
rescue => e
|
|
1273
|
+
error "Failed to read file: #{e.message}"
|
|
1274
|
+
attempts += 1
|
|
1275
|
+
next
|
|
1276
|
+
end
|
|
1277
|
+
else
|
|
1278
|
+
error "File not found: #{json_path}"
|
|
1279
|
+
attempts += 1
|
|
1280
|
+
|
|
1281
|
+
if attempts < max_retries
|
|
1282
|
+
say ""
|
|
1283
|
+
say "Please try again (attempt #{attempts + 1}/#{max_retries})", :yellow
|
|
1284
|
+
say ""
|
|
1285
|
+
end
|
|
1286
|
+
end
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
unless service_account_json
|
|
1290
|
+
say ""
|
|
1291
|
+
error "Could not read JSON file after #{max_retries} attempts"
|
|
1292
|
+
say "Setup skipped. Run 'mysigner doctor' to try again.", :yellow
|
|
1293
|
+
return false
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
# Prompt for credential name
|
|
1297
|
+
say "Step 3: Name this credential", :bold
|
|
1298
|
+
say ""
|
|
1299
|
+
say "Choose a name to identify this service account (e.g., 'Production', 'CI/CD')", :cyan
|
|
1300
|
+
say "Default: 'CLI Setup' - just press Enter to use it", :cyan
|
|
1301
|
+
say ""
|
|
1302
|
+
|
|
1303
|
+
credential_name = nil
|
|
1304
|
+
while credential_name.nil? || credential_name.empty?
|
|
1305
|
+
name_input = ask("Credential name:").strip
|
|
1306
|
+
credential_name = name_input.empty? ? "CLI Setup" : name_input
|
|
1307
|
+
|
|
1308
|
+
if credential_name.empty?
|
|
1309
|
+
error "Name cannot be empty"
|
|
1310
|
+
say ""
|
|
1311
|
+
end
|
|
1312
|
+
end
|
|
1313
|
+
say ""
|
|
1314
|
+
say "→ Using name: '#{credential_name}'", :cyan
|
|
1315
|
+
say ""
|
|
1316
|
+
|
|
1317
|
+
# Validate and upload
|
|
1318
|
+
say "Step 4: Saving credentials...", :bold
|
|
1319
|
+
say ""
|
|
1320
|
+
|
|
1321
|
+
begin
|
|
1322
|
+
response = client.post("/api/v1/organizations/#{org_id}/google_play_credentials",
|
|
1323
|
+
body: {
|
|
1324
|
+
google_play_credential: {
|
|
1325
|
+
name: credential_name,
|
|
1326
|
+
service_account_json: service_account_json,
|
|
1327
|
+
active: true
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
)
|
|
1331
|
+
|
|
1332
|
+
say "✓ Google Play credentials saved!", :green
|
|
1333
|
+
say ""
|
|
1334
|
+
say "Details:", :cyan
|
|
1335
|
+
say " • Name: #{credential_name}"
|
|
1336
|
+
say " • Status: Active ✓"
|
|
1337
|
+
say ""
|
|
1338
|
+
|
|
1339
|
+
# Test the connection
|
|
1340
|
+
say "Testing connection to Google Play...", :yellow
|
|
1341
|
+
|
|
1342
|
+
begin
|
|
1343
|
+
cred_id = response[:data]['id']
|
|
1344
|
+
client.post("/api/v1/organizations/#{org_id}/google_play_credentials/#{cred_id}/test")
|
|
1345
|
+
say "✓ Successfully connected to Google Play API!", :green
|
|
1346
|
+
rescue => e
|
|
1347
|
+
say "⚠️ Connection test failed: #{e.message}", :yellow
|
|
1348
|
+
say " The credentials are saved but may need verification", :yellow
|
|
1349
|
+
end
|
|
1350
|
+
|
|
1351
|
+
say ""
|
|
1352
|
+
say "🎉 Google Play is now configured!", :green
|
|
1353
|
+
say ""
|
|
1354
|
+
return true
|
|
1355
|
+
|
|
1356
|
+
rescue Mysigner::ClientError => e
|
|
1357
|
+
error_msg = e.message
|
|
1358
|
+
|
|
1359
|
+
if error_msg.include?("already been taken") || error_msg.include?("validation")
|
|
1360
|
+
error "A credential with the name '#{credential_name}' already exists"
|
|
1361
|
+
say ""
|
|
1362
|
+
say "Please choose a different name and try again.", :yellow
|
|
1363
|
+
else
|
|
1364
|
+
error "Failed to configure credentials: #{error_msg}"
|
|
1365
|
+
end
|
|
1366
|
+
|
|
1367
|
+
say ""
|
|
1368
|
+
say "Setup skipped. Run 'mysigner doctor' to try again.", :yellow
|
|
1369
|
+
return false
|
|
1370
|
+
rescue => e
|
|
1371
|
+
error "Unexpected error: #{e.message}"
|
|
1372
|
+
say "Setup skipped. Run 'mysigner doctor' to try again.", :yellow
|
|
1373
|
+
return false
|
|
1374
|
+
end
|
|
1375
|
+
end
|
|
1376
|
+
end
|
|
1377
|
+
end
|
|
1378
|
+
end
|
|
1379
|
+
end
|
|
1380
|
+
end
|
|
1381
|
+
end
|