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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.DS_Store +0 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +3 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +7 -0
  7. data/CODE_OF_CONDUCT.md +74 -0
  8. data/Gemfile +7 -0
  9. data/Gemfile.lock +137 -0
  10. data/LICENSE +201 -0
  11. data/MANUAL_TEST.md +341 -0
  12. data/README.md +493 -0
  13. data/Rakefile +6 -0
  14. data/bin/console +14 -0
  15. data/bin/setup +8 -0
  16. data/exe/mysigner +5 -0
  17. data/lib/mysigner/build/android_executor.rb +367 -0
  18. data/lib/mysigner/build/android_parser.rb +293 -0
  19. data/lib/mysigner/build/configurator.rb +126 -0
  20. data/lib/mysigner/build/detector.rb +388 -0
  21. data/lib/mysigner/build/error_analyzer.rb +193 -0
  22. data/lib/mysigner/build/executor.rb +176 -0
  23. data/lib/mysigner/build/parser.rb +206 -0
  24. data/lib/mysigner/cli/auth_commands.rb +1381 -0
  25. data/lib/mysigner/cli/build_commands.rb +2095 -0
  26. data/lib/mysigner/cli/concerns/actionable_suggestions.rb +500 -0
  27. data/lib/mysigner/cli/concerns/api_helpers.rb +131 -0
  28. data/lib/mysigner/cli/concerns/error_handlers.rb +446 -0
  29. data/lib/mysigner/cli/concerns/helpers.rb +63 -0
  30. data/lib/mysigner/cli/diagnostic_commands.rb +1034 -0
  31. data/lib/mysigner/cli/resource_commands.rb +2670 -0
  32. data/lib/mysigner/cli.rb +43 -0
  33. data/lib/mysigner/client.rb +189 -0
  34. data/lib/mysigner/config.rb +311 -0
  35. data/lib/mysigner/export/exporter.rb +150 -0
  36. data/lib/mysigner/signing/certificate_checker.rb +148 -0
  37. data/lib/mysigner/signing/keystore_manager.rb +239 -0
  38. data/lib/mysigner/signing/validator.rb +150 -0
  39. data/lib/mysigner/signing/wizard.rb +784 -0
  40. data/lib/mysigner/upload/app_store_automation.rb +402 -0
  41. data/lib/mysigner/upload/app_store_submission.rb +312 -0
  42. data/lib/mysigner/upload/play_store_uploader.rb +378 -0
  43. data/lib/mysigner/upload/uploader.rb +373 -0
  44. data/lib/mysigner/version.rb +3 -0
  45. data/lib/mysigner.rb +15 -0
  46. data/mysigner.gemspec +78 -0
  47. data/test_manual.rb +102 -0
  48. 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