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,1034 @@
1
+ module Mysigner
2
+ class CLI < Thor
3
+ module DiagnosticCommands
4
+ def self.included(base)
5
+ base.class_eval do
6
+ desc "doctor", "🩺 Run health check and diagnose setup issues (run this if stuck)"
7
+ method_option :platform, type: :string, desc: 'Check specific platform only: ios, android, or all (default)'
8
+ def doctor
9
+ say "🩺 My Signer Health Check", :cyan
10
+ say "=" * 80, :cyan
11
+ say ""
12
+
13
+ issues = []
14
+ warnings = []
15
+
16
+ # Determine which platforms to check
17
+ platform_filter = options[:platform]&.downcase
18
+ check_ios = platform_filter.nil? || platform_filter == 'all' || platform_filter == 'ios'
19
+ check_android = platform_filter.nil? || platform_filter == 'all' || platform_filter == 'android'
20
+
21
+ if platform_filter && !%w[ios android all].include?(platform_filter)
22
+ error "Invalid platform: #{platform_filter}"
23
+ say "Valid options: ios, android, all", :yellow
24
+ exit 1
25
+ end
26
+
27
+ # Check 1: Xcode (iOS only)
28
+ if check_ios
29
+ say "Checking Xcode...", :yellow
30
+ if system('which xcodebuild > /dev/null 2>&1')
31
+ xcode_version = `xcodebuild -version`.lines.first.strip rescue "Unknown"
32
+ say " ✓ Xcode installed: #{xcode_version}", :green
33
+ else
34
+ say " ✗ Xcode not found", :red
35
+ issues << "Xcode is not installed or not in PATH"
36
+ end
37
+ say ""
38
+
39
+ # Check 2: Command Line Tools
40
+ say "Checking Command Line Tools...", :yellow
41
+ if system('xcode-select -p > /dev/null 2>&1')
42
+ say " ✓ Command Line Tools installed", :green
43
+ else
44
+ say " ✗ Command Line Tools not found", :red
45
+ issues << "Install with: xcode-select --install"
46
+ end
47
+ say ""
48
+
49
+ # Check 3: xcrun altool
50
+ say "Checking upload tools...", :yellow
51
+ if system('xcrun --find altool > /dev/null 2>&1')
52
+ say " ✓ xcrun altool available", :green
53
+ else
54
+ say " ⚠️ xcrun altool not found", :yellow
55
+ warnings << "altool not available (upload may fail)"
56
+ end
57
+
58
+ # Check for iTMSTransporter
59
+ transporter_paths = [
60
+ '/Applications/Xcode.app/Contents/Developer/usr/bin/iTMSTransporter',
61
+ '/Applications/Transporter.app/Contents/itms/bin/iTMSTransporter'
62
+ ]
63
+ transporter_found = transporter_paths.any? { |path| File.exist?(path) }
64
+
65
+ if transporter_found
66
+ say " ✓ iTMSTransporter available (fallback)", :green
67
+ else
68
+ say " ⚠️ iTMSTransporter not found (optional)", :yellow
69
+ end
70
+ say ""
71
+
72
+ # Check 4: My Signer Configuration
73
+ say "Checking My Signer configuration...", :yellow
74
+ config = Config.new
75
+ client = nil
76
+ org_data = nil
77
+
78
+ if config.exists?
79
+ config.load
80
+ say " ✓ Logged in", :green
81
+
82
+ begin
83
+ client = Client.new(api_url: config.api_url, api_token: config.api_token)
84
+ client.test_connection
85
+ say " ✓ API connection working", :green
86
+
87
+ # Get organization details
88
+ if config.current_organization_id
89
+ org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
90
+ org_data = org_response[:data]
91
+ end
92
+ rescue Mysigner::UnauthorizedError
93
+ say " ✗ Token is invalid or expired", :red
94
+ issues << "Token authentication failed - run 'mysigner onboard' to re-authenticate"
95
+ client = nil
96
+ rescue Mysigner::ConnectionError => e
97
+ say " ✗ Cannot connect to API: #{e.message}", :red
98
+ issues << "API connection failed - check your network or API URL"
99
+ client = nil
100
+ rescue => e
101
+ say " ✗ API error: #{e.message}", :red
102
+ issues << "API connection failed - check your configuration"
103
+ client = nil
104
+ end
105
+ else
106
+ say " ✗ Not logged in", :red
107
+ issues << "Run 'mysigner onboard' to authenticate"
108
+ end
109
+ say ""
110
+
111
+ # Check 4a: Signing Identity in Keychain (CRITICAL)
112
+ if client && org_data && org_data['app_store_connect_team_id']
113
+ say "Checking signing identity for team...", :yellow
114
+ team_id = org_data['app_store_connect_team_id']
115
+
116
+ # Check if signing identities exist in keychain for this team
117
+ identities = `security find-identity -v -p codesigning 2>/dev/null | grep -i "#{team_id}"`
118
+ has_identity = $?.success? && !identities.strip.empty?
119
+
120
+ if has_identity
121
+ say " ✓ Signing identity found for team #{team_id}", :green
122
+ else
123
+ say " ✗ No signing identity for team #{team_id}", :red
124
+ say ""
125
+ say " CRITICAL: You need to sign into Xcode and download certificates:", :red
126
+ say " 1. Open Xcode → Settings → Accounts", :yellow
127
+ say " 2. Verify you're signed in with your Apple ID", :yellow
128
+ say " 3. Select team '#{team_id}'", :yellow
129
+ say " 4. Click 'Download Manual Profiles' (or 'Manage Certificates')", :yellow
130
+ say " 5. The certificate should appear in keychain", :yellow
131
+ say ""
132
+ issues << "No signing identity for team #{team_id} in keychain"
133
+ end
134
+ say ""
135
+ end
136
+
137
+ # Check 4b: App Store Connect Credentials (with auto-fix)
138
+ if client && org_data
139
+ say "Checking App Store Connect credentials...", :yellow
140
+ creds_status = org_data['credentials_status'] || {}
141
+
142
+ if creds_status['needs_setup'] || !org_data['app_store_connect_configured']
143
+ say " ✗ App Store Connect not configured", :red
144
+ say ""
145
+
146
+ if yes_with_default?("Would you like to set it up now?", :green)
147
+ say ""
148
+ # Call the setup helper (available from AuthCommands module)
149
+ if respond_to?(:setup_app_store_connect_credentials, true)
150
+ asc_configured = setup_app_store_connect_credentials(client, config, config.current_organization_id)
151
+ else
152
+ say " ✗ Setup helper not available", :red
153
+ say " Please run 'mysigner onboard' instead", :yellow
154
+ asc_configured = false
155
+ end
156
+
157
+ if asc_configured
158
+ say ""
159
+ say " ✓ App Store Connect configured successfully!", :green
160
+ say ""
161
+ # Refresh org data
162
+ org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
163
+ org_data = org_response[:data]
164
+ else
165
+ say ""
166
+ issues << "App Store Connect setup incomplete - run 'mysigner onboard' to try again"
167
+ end
168
+ else
169
+ issues << "App Store Connect not configured - run 'mysigner onboard' to set it up"
170
+ end
171
+ elsif !creds_status['team_id_set']
172
+ say " ⚠️ Team ID not set", :yellow
173
+ warnings << "Team ID missing - may cause issues. Re-sync to extract it."
174
+ else
175
+ say " ✓ App Store Connect configured", :green
176
+ if org_data['app_store_connect_team_id']
177
+ say " Team ID: #{org_data['app_store_connect_team_id']}", :cyan
178
+ end
179
+ end
180
+ say ""
181
+ end
182
+
183
+ # Check 5: Xcode License Agreement
184
+ say "Checking Xcode license...", :yellow
185
+ begin
186
+ license_check = `sudo -n xcodebuild -checkFirstLaunchStatus 2>&1`
187
+ license_status = $?.success?
188
+
189
+ if license_status
190
+ say " ✓ Xcode license accepted", :green
191
+ else
192
+ # Check if it's a permission issue or actual license issue
193
+ if license_check.include?("password") || license_check.include?("sudo")
194
+ say " ℹ️ Cannot check license (needs sudo)", :cyan
195
+ else
196
+ say " ⚠️ Xcode license may not be accepted", :yellow
197
+ say " Run: sudo xcodebuild -license accept", :cyan
198
+ warnings << "Xcode license may need acceptance"
199
+ end
200
+ end
201
+ rescue
202
+ say " ℹ️ Could not check Xcode license", :cyan
203
+ end
204
+ say ""
205
+
206
+ # Check 6: Disk Space
207
+ say "Checking disk space...", :yellow
208
+ begin
209
+ df_output = `df -h . 2>/dev/null | tail -1`.strip
210
+ if df_output =~ /(\d+)%/
211
+ usage = $1.to_i
212
+ if usage > 95
213
+ say " ✗ Critical: Disk space very low (#{usage}% used)", :red
214
+ issues << "Free up disk space before building"
215
+ elsif usage > 90
216
+ say " ⚠️ Low disk space: #{usage}% used", :yellow
217
+ warnings << "Low disk space may cause build failures"
218
+ else
219
+ say " ✓ Sufficient disk space: #{usage}% used", :green
220
+ end
221
+ else
222
+ say " ⚠️ Could not check disk space", :yellow
223
+ end
224
+ rescue
225
+ say " ⚠️ Could not check disk space", :yellow
226
+ end
227
+ say ""
228
+
229
+ # Check 7: Network Connectivity
230
+ say "Checking network connectivity...", :yellow
231
+ begin
232
+ require 'socket'
233
+ Socket.tcp("apple.com", 443, connect_timeout: 5) { |sock| sock.close }
234
+ say " ✓ Internet connection working", :green
235
+ rescue => e
236
+ say " ✗ No internet connection", :red
237
+ issues << "Cannot reach Apple servers - check your network connection"
238
+ end
239
+ say ""
240
+
241
+ # Check 8: Project Detection (if in a project directory)
242
+ say "Checking current directory...", :yellow
243
+ project_info = nil
244
+ begin
245
+ project_info = Build::Detector.detect
246
+ framework = case project_info[:framework]
247
+ when :capacitor then "Capacitor/Ionic"
248
+ when :react_native then "React Native"
249
+ when :flutter then "Flutter"
250
+ else "Native iOS"
251
+ end
252
+ say " ✓ Found #{framework} project: #{File.basename(project_info[:path])}", :green
253
+ rescue
254
+ say " ℹ️ No project detected in current directory", :cyan
255
+ end
256
+ say ""
257
+
258
+ # Check 9: Organization Resources Health (if logged in)
259
+ if client && org_data && org_data['app_store_connect_configured']
260
+ say "Checking organization resources...", :yellow
261
+
262
+ stats = org_data['stats'] || {}
263
+
264
+ # Check certificates
265
+ certs_count = stats['certificates_count'] || 0
266
+ if certs_count == 0
267
+ say " ⚠️ No certificates synced", :yellow
268
+ warnings << "Run sync in web dashboard to fetch certificates from Apple"
269
+ else
270
+ say " ✓ Certificates: #{certs_count}", :green
271
+ end
272
+
273
+ # Check devices
274
+ devices_count = stats['devices_count'] || 0
275
+ if devices_count == 0
276
+ say " ⚠️ No devices registered", :yellow
277
+ warnings << "Add devices for development/adhoc builds"
278
+ else
279
+ say " ✓ Devices: #{devices_count}", :green
280
+ end
281
+
282
+ # Check bundle IDs
283
+ bundle_ids_count = stats['bundle_ids_count'] || 0
284
+ if bundle_ids_count == 0
285
+ say " ⚠️ No bundle IDs synced", :yellow
286
+ say " This is normal for new accounts", :cyan
287
+ else
288
+ say " ✓ Bundle IDs: #{bundle_ids_count}", :green
289
+ end
290
+
291
+ # Check profiles
292
+ profiles_count = stats['profiles_count'] || 0
293
+ invalid_profiles = stats['invalid_profiles_count'] || 0
294
+
295
+ if profiles_count == 0
296
+ say " ⚠️ No provisioning profiles", :yellow
297
+ warnings << "Create profiles for your projects"
298
+ elsif invalid_profiles > 0
299
+ say " ✓ Profiles: #{profiles_count} (⚠️ #{invalid_profiles} invalid)", :yellow
300
+ warnings << "#{invalid_profiles} profile(s) invalid - may need regeneration"
301
+ else
302
+ say " ✓ Profiles: #{profiles_count}", :green
303
+ end
304
+
305
+ say ""
306
+ end
307
+
308
+ # Check 10: Project Signing Setup (if project detected and logged in)
309
+ if project_info && client && org_data && org_data['app_store_connect_configured']
310
+ say "Checking project signing setup...", :yellow
311
+
312
+ begin
313
+ parser = Build::Parser.new(project_info)
314
+ main_target = parser.main_target
315
+
316
+ if main_target
317
+ target_name = main_target.name
318
+ bundle_id = parser.bundle_id(target_name, 'Release')
319
+
320
+ say " Project: #{target_name}", :cyan
321
+ say " Bundle ID: #{bundle_id}", :cyan
322
+ say ""
323
+
324
+ # First check if bundle ID is registered
325
+ say " Checking bundle ID registration...", :yellow
326
+
327
+ bundle_ids_response = client.get(
328
+ "/api/v1/organizations/#{config.current_organization_id}/bundle_ids",
329
+ params: { q: bundle_id }
330
+ )
331
+
332
+ bundle_id_exists = (bundle_ids_response[:data]['bundle_ids'] || []).any? do |bid|
333
+ bid['identifier'] == bundle_id
334
+ end
335
+
336
+ if !bundle_id_exists
337
+ say " ✗ Bundle ID '#{bundle_id}' not registered in App Store Connect", :red
338
+ say ""
339
+
340
+ # Show what bundle IDs ARE registered
341
+ all_bundle_ids = bundle_ids_response[:data]['bundle_ids'] || []
342
+ if all_bundle_ids.any?
343
+ say " Registered bundle IDs in your organization:", :cyan
344
+ all_bundle_ids.first(5).each do |bid|
345
+ say " • #{bid['identifier']}", :cyan
346
+ end
347
+ if all_bundle_ids.length > 5
348
+ say " ... and #{all_bundle_ids.length - 5} more", :cyan
349
+ end
350
+ say ""
351
+ end
352
+
353
+ say " Options:", :yellow
354
+ say " A. Register '#{bundle_id}' in App Store Connect:", :yellow
355
+ say " 1. Go to: https://developer.apple.com/account/resources/identifiers/add", :cyan
356
+ say " 2. Select 'App IDs'", :cyan
357
+ say " 3. Register: #{bundle_id}", :cyan
358
+ say " 4. Sync in web dashboard", :cyan
359
+ say " 5. Run 'mysigner doctor' again", :cyan
360
+ say ""
361
+ say " B. Or change your Xcode project to use an existing bundle ID", :yellow
362
+ say ""
363
+ issues << "Bundle ID #{bundle_id} not registered in App Store Connect"
364
+ else
365
+ say " ✓ Bundle ID registered", :green
366
+
367
+ # Check if profiles exist for this bundle ID
368
+ say " Checking provisioning profiles...", :yellow
369
+
370
+ profiles_response = client.get(
371
+ "/api/v1/organizations/#{config.current_organization_id}/profiles",
372
+ params: { bundle_id: bundle_id }
373
+ )
374
+
375
+ profiles = profiles_response[:data]['profiles'] || []
376
+
377
+ # Check for App Store profile
378
+ appstore_profiles = profiles.select { |p| p['profile_type'] == 'IOS_APP_STORE' && p['state'] == 'ACTIVE' }
379
+
380
+ if appstore_profiles.empty?
381
+ say " ✗ No App Store provisioning profile for #{bundle_id}", :red
382
+ say ""
383
+
384
+ if yes_with_default?("Create App Store profile automatically?", :green)
385
+ say ""
386
+ auto_create_profile(client, config, bundle_id, 'appstore')
387
+ else
388
+ issues << "Missing App Store profile for #{bundle_id} - run 'mysigner signing configure'"
389
+ end
390
+ else
391
+ say " ✓ App Store provisioning profile exists", :green
392
+
393
+ # Check if expired
394
+ expired = appstore_profiles.select do |p|
395
+ expires_at = Time.parse(p['expires_at']) rescue nil
396
+ expires_at && expires_at < Time.now
397
+ end
398
+
399
+ if expired.any?
400
+ say " ⚠️ #{expired.length} profile(s) expired", :yellow
401
+ warnings << "Some profiles are expired - sync to refresh"
402
+ end
403
+ end
404
+
405
+ # Check for development profile (helpful for testing)
406
+ dev_profiles = profiles.select { |p| p['profile_type'] == 'IOS_APP_DEVELOPMENT' && p['state'] == 'ACTIVE' }
407
+
408
+ if dev_profiles.empty?
409
+ say " ⚠️ No Development profile (optional but recommended)", :yellow
410
+ say ""
411
+ say " 📱 Development profiles let you:", :cyan
412
+ say " • Test your app on physical devices (iPhone/iPad)", :cyan
413
+ say " • Debug before uploading to TestFlight", :cyan
414
+ say " • Share with your team for testing", :cyan
415
+ say ""
416
+
417
+ if yes_with_default?("Create Development profile for local testing?", :yellow)
418
+ say ""
419
+ auto_create_profile(client, config, bundle_id, 'development')
420
+ else
421
+ warnings << "No development profile - you won't be able to test on devices"
422
+ end
423
+ else
424
+ say " ✓ Development profile exists", :green
425
+ end
426
+ end
427
+ end
428
+ rescue => e
429
+ say " ⚠️ Could not check project signing: #{e.message}", :yellow
430
+ warnings << "Project signing check failed"
431
+ end
432
+ say ""
433
+ elsif project_info && (!client || !org_data)
434
+ say "⚠️ Project detected but cannot check signing (not logged in)", :yellow
435
+ say ""
436
+ end
437
+ end # end of check_ios block
438
+
439
+ # ==================== ANDROID CHECKS ====================
440
+ if check_android
441
+ say "Checking Android development environment...", :yellow
442
+ android_available = false
443
+
444
+ # Check 11: Java/JDK
445
+ if system('which java > /dev/null 2>&1')
446
+ java_version = `java -version 2>&1`.lines.first.strip rescue "Unknown"
447
+ say " ✓ Java installed: #{java_version}", :green
448
+ android_available = true
449
+
450
+ # Check JAVA_HOME validity
451
+ java_home = ENV['JAVA_HOME']
452
+ if java_home && !java_home.empty?
453
+ if Dir.exist?(java_home)
454
+ say " ✓ JAVA_HOME: #{java_home}", :green
455
+ else
456
+ say " ✗ JAVA_HOME invalid: #{java_home}", :red
457
+
458
+ # Try to auto-detect correct JAVA_HOME
459
+ detected_java_home = detect_java_home
460
+ if detected_java_home
461
+ say " 💡 Detected valid Java at: #{detected_java_home}", :yellow
462
+ say ""
463
+ if yes_with_default?(" Would you like to fix JAVA_HOME in your shell config?", :green)
464
+ fix_java_home(detected_java_home)
465
+ else
466
+ say " To fix manually, add to ~/.zshrc:", :yellow
467
+ say " export JAVA_HOME=#{detected_java_home}", :cyan
468
+ issues << "JAVA_HOME points to non-existent directory"
469
+ end
470
+ else
471
+ issues << "JAVA_HOME points to non-existent directory and no Java found"
472
+ end
473
+ end
474
+ else
475
+ # JAVA_HOME not set - try to detect and suggest
476
+ detected_java_home = detect_java_home
477
+ if detected_java_home
478
+ say " ⚠️ JAVA_HOME not set", :yellow
479
+ say " 💡 Detected Java at: #{detected_java_home}", :yellow
480
+ say ""
481
+ if yes_with_default?(" Would you like to set JAVA_HOME in your shell config?", :green)
482
+ fix_java_home(detected_java_home)
483
+ else
484
+ warnings << "JAVA_HOME not set (recommended: export JAVA_HOME=#{detected_java_home})"
485
+ end
486
+ else
487
+ say " ⚠️ JAVA_HOME not set", :yellow
488
+ end
489
+ end
490
+ else
491
+ say " ℹ️ Java not found (required for Android)", :cyan
492
+ end
493
+
494
+ # Check 12: Android SDK
495
+ android_home = ENV['ANDROID_HOME'] || ENV['ANDROID_SDK_ROOT']
496
+ if android_home && Dir.exist?(android_home)
497
+ say " ✓ Android SDK: #{android_home}", :green
498
+ android_available = true
499
+ else
500
+ say " ℹ️ Android SDK not found (set ANDROID_HOME)", :cyan
501
+ end
502
+
503
+ # Check 13: Gradle
504
+ if system('which gradle > /dev/null 2>&1') || (android_home && File.exist?("#{android_home}/../gradle"))
505
+ gradle_version = `gradle --version 2>&1 | grep 'Gradle '`.strip rescue ""
506
+ if gradle_version.empty?
507
+ say " ✓ Gradle available (version check skipped)", :green
508
+ else
509
+ say " ✓ #{gradle_version}", :green
510
+ end
511
+ else
512
+ say " ℹ️ Gradle not found (will use project gradlew)", :cyan
513
+ end
514
+ say ""
515
+
516
+ # Check 14: Google Play credentials (if logged in)
517
+ if client && org_data
518
+ say "Checking Google Play configuration...", :yellow
519
+
520
+ if org_data['google_play_configured']
521
+ say " ✓ Google Play credentials configured", :green
522
+ else
523
+ say " ℹ️ Google Play not configured", :cyan
524
+ say " Configure in My Signer dashboard or run 'mysigner doctor'", :cyan
525
+ end
526
+
527
+ # Check for keystores
528
+ begin
529
+ require_relative '../signing/keystore_manager'
530
+ manager = Signing::KeystoreManager.new(client, config.current_organization_id)
531
+ keystores = manager.list
532
+
533
+ if keystores.any?
534
+ active = keystores.find { |k| k['active'] }
535
+ if active
536
+ say " ✓ Active keystore: #{active['name']}", :green
537
+ else
538
+ say " ⚠️ #{keystores.count} keystores, none active", :yellow
539
+ warnings << "No active keystore - activate one with: mysigner keystore activate ID"
540
+ end
541
+ else
542
+ say " ℹ️ No Android keystores", :cyan
543
+ say " Upload with: mysigner keystore upload PATH", :cyan
544
+ end
545
+ rescue => e
546
+ say " ⚠️ Could not check keystores: #{e.message}", :yellow
547
+ end
548
+ say ""
549
+ end
550
+
551
+ # Check 15: Android Project Detection
552
+ android_project = nil
553
+ begin
554
+ android_project = Build::Detector.detect_android
555
+ framework = case android_project[:framework]
556
+ when :capacitor then "Capacitor/Ionic"
557
+ when :react_native then "React Native"
558
+ when :flutter then "Flutter"
559
+ else "Native Android"
560
+ end
561
+ say "Checking Android project...", :yellow
562
+ say " ✓ Found #{framework} Android project", :green
563
+
564
+ # Parse project details
565
+ require_relative '../build/android_parser'
566
+ parser = Build::AndroidParser.new(android_project)
567
+ say " Package: #{parser.application_id}", :cyan
568
+ say " Version: #{parser.version_name} (#{parser.version_code})", :cyan
569
+ say " Gradle wrapper: #{parser.gradle_wrapper_exists? ? '✓' : '✗'}", :cyan
570
+ say ""
571
+ rescue Build::Detector::NoProjectError
572
+ # Not an Android project, that's fine
573
+ rescue => e
574
+ say " ⚠️ Could not analyze Android project: #{e.message}", :yellow if android_available
575
+ end
576
+ end # end of check_android block
577
+
578
+ # Final Report
579
+ say "=" * 80, :cyan
580
+ say "Health Report", :bold
581
+ say "=" * 80, :cyan
582
+ say ""
583
+
584
+ if issues.empty? && warnings.empty?
585
+ say "🎉 All checks passed! You're good to go!", :green
586
+ say ""
587
+ say "Try: mysigner ship testflight", :cyan
588
+ elsif issues.empty?
589
+ say "⚠️ #{warnings.length} warning(s), but you're mostly good!", :yellow
590
+ say ""
591
+ warnings.each do |warning|
592
+ say " • #{warning}", :yellow
593
+ end
594
+ else
595
+ say "✗ #{issues.length} issue(s) found:", :red
596
+ say ""
597
+ issues.each do |issue|
598
+ say " • #{issue}", :red
599
+ end
600
+
601
+ if warnings.any?
602
+ say ""
603
+ say "⚠️ #{warnings.length} warning(s):", :yellow
604
+ warnings.each do |warning|
605
+ say " • #{warning}", :yellow
606
+ end
607
+ end
608
+ end
609
+
610
+ say ""
611
+ end
612
+
613
+ no_commands do
614
+ # Helper method for yes/no prompts with Enter defaulting to yes
615
+ def yes_with_default?(statement, color = nil)
616
+ response = ask("#{statement} [Y/n]", color).to_s.strip.downcase
617
+ response.empty? || response == 'y' || response == 'yes'
618
+ end
619
+
620
+ # Generate a Certificate Signing Request (CSR)
621
+ def generate_csr(email)
622
+ require 'openssl'
623
+
624
+ say " Generating CSR...", :cyan
625
+
626
+ begin
627
+ # Save to Downloads (visible in file picker)
628
+ csr_dir = File.expand_path("~/Downloads")
629
+ FileUtils.mkdir_p(csr_dir)
630
+
631
+ # Generate RSA key pair
632
+ key = OpenSSL::PKey::RSA.new(2048)
633
+
634
+ # Create CSR
635
+ csr = OpenSSL::X509::Request.new
636
+ csr.version = 0
637
+ csr.subject = OpenSSL::X509::Name.new([
638
+ ['CN', email || 'My Signer User'],
639
+ ['emailAddress', email || 'user@example.com']
640
+ ])
641
+ csr.public_key = key.public_key
642
+ csr.sign(key, OpenSSL::Digest::SHA256.new)
643
+
644
+ # Generate unique filename with timestamp
645
+ timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
646
+ csr_filename = "CertificateSigningRequest_#{timestamp}.certSigningRequest"
647
+ key_filename = "private_key_#{timestamp}.pem"
648
+
649
+ # Save CSR to Downloads (visible)
650
+ csr_path = File.join(csr_dir, csr_filename)
651
+
652
+ # Save private key to hidden location (secure)
653
+ key_dir = File.expand_path("~/.mysigner/keys")
654
+ FileUtils.mkdir_p(key_dir)
655
+ key_path = File.join(key_dir, key_filename)
656
+
657
+ # Save CSR file
658
+ File.write(csr_path, csr.to_pem)
659
+
660
+ # Import private key directly to keychain (so certificate can pair)
661
+ File.write(key_path, key.to_pem)
662
+
663
+ import_result = `security import #{key_path} -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign -T /usr/bin/security 2>&1`
664
+ import_success = $?.success?
665
+
666
+ if import_success
667
+ say " ✓ CSR saved to Downloads", :green
668
+ say " ✓ Private key imported to keychain", :green
669
+ # Clean up the file after importing
670
+ File.delete(key_path) rescue nil
671
+ else
672
+ say " ✓ CSR saved to Downloads", :green
673
+ say " ✓ Private key saved to: #{key_path}", :green
674
+ say " ⚠️ Import it with: security import #{key_path} -k ~/Library/Keychains/login.keychain-db", :yellow
675
+ end
676
+
677
+ csr_path
678
+ rescue => e
679
+ say " ✗ Failed to generate CSR: #{e.message}", :red
680
+ nil
681
+ end
682
+ end
683
+
684
+ # Helper to auto-create a provisioning profile
685
+ def auto_create_profile(client, config, bundle_id, profile_type)
686
+ say "Creating #{profile_type} profile for #{bundle_id}...", :yellow
687
+ say ""
688
+
689
+ # Map friendly names to Apple's profile types
690
+ apple_profile_type = case profile_type.to_s.downcase
691
+ when 'appstore', 'store' then 'IOS_APP_STORE'
692
+ when 'development', 'dev' then 'IOS_APP_DEVELOPMENT'
693
+ when 'adhoc' then 'IOS_APP_ADHOC'
694
+ else profile_type
695
+ end
696
+
697
+ begin
698
+ # First, ensure resources are synced
699
+ say " Syncing organization resources...", :cyan
700
+ client.post("/api/v1/organizations/#{config.current_organization_id}/sync_app_store_connect")
701
+
702
+ # Wait a bit for sync to complete
703
+ sleep 2
704
+
705
+ # Check sync status
706
+ max_wait = 15 # seconds
707
+ waited = 0
708
+ sync_complete = false
709
+
710
+ while waited < max_wait
711
+ status_response = client.get("/api/v1/organizations/#{config.current_organization_id}/sync/status")
712
+ sync_data = status_response[:data]['sync']
713
+
714
+ if !sync_data['running']
715
+ sync_complete = true
716
+ break
717
+ end
718
+
719
+ sleep 1
720
+ waited += 1
721
+ end
722
+
723
+ if sync_complete
724
+ say " ✓ Sync complete", :green
725
+ else
726
+ say " ⚠️ Sync still running, continuing anyway...", :yellow
727
+ end
728
+ say ""
729
+
730
+ # Create profile
731
+ say " Creating #{apple_profile_type} profile...", :cyan
732
+ response = client.post(
733
+ "/api/v1/organizations/#{config.current_organization_id}/profiles/auto_create",
734
+ body: {
735
+ bundle_id: bundle_id,
736
+ profile_type: apple_profile_type
737
+ }
738
+ )
739
+
740
+ if response[:success]
741
+ profile = response[:data]['profile']
742
+ say " ✓ Created profile: #{profile['name']}", :green
743
+ say " UUID: #{profile['uuid']}", :cyan
744
+ say " Expires: #{profile['expires_at']}", :cyan
745
+ say ""
746
+
747
+ # Download and install the profile using direct Faraday for binary data
748
+ say " Downloading profile...", :cyan
749
+ download_url = "/api/v1/organizations/#{config.current_organization_id}/profiles/#{profile['id']}/download"
750
+
751
+ conn = Faraday.new(url: config.api_url) do |f|
752
+ f.request :authorization, 'Bearer', config.api_token
753
+ f.adapter Faraday.default_adapter
754
+ end
755
+
756
+ download_response = conn.get(download_url) do |req|
757
+ req.options.timeout = 30
758
+ req.options.open_timeout = 10
759
+ end
760
+
761
+ if download_response.success?
762
+ # Install to Xcode's provisioning profiles directory
763
+ profiles_dir = File.expand_path("~/Library/MobileDevice/Provisioning Profiles")
764
+ FileUtils.mkdir_p(profiles_dir)
765
+ profile_path = File.join(profiles_dir, "#{profile['uuid']}.mobileprovision")
766
+ File.binwrite(profile_path, download_response.body)
767
+
768
+ say " ✓ Profile installed to Xcode", :green
769
+ else
770
+ say " ⚠️ Could not download profile: HTTP #{download_response.status}", :yellow
771
+ end
772
+ say ""
773
+ true
774
+ else
775
+ say " ✗ Failed to create profile", :red
776
+ false
777
+ end
778
+ rescue Mysigner::ClientError => e
779
+ error_msg = e.message
780
+
781
+ if error_msg.include?("bundle_id_not_found")
782
+ say " ✗ Bundle ID '#{bundle_id}' not found in App Store Connect", :red
783
+ say ""
784
+ say " You need to register this bundle ID first:", :yellow
785
+ say " 1. Go to: https://developer.apple.com/account/resources/identifiers/list", :cyan
786
+ say " 2. Register bundle ID: #{bundle_id}", :cyan
787
+ say " 3. Run 'mysigner doctor' again", :cyan
788
+ elsif error_msg.include?("certificates found") || error_msg.include?("no_certificates")
789
+ cert_type = apple_profile_type == 'IOS_APP_STORE' ? "Distribution" : "Development"
790
+ cert_name = apple_profile_type == 'IOS_APP_STORE' ? "Apple Distribution" : "Apple Development"
791
+
792
+ say " ✗ No #{cert_type} certificates found", :red
793
+ say ""
794
+
795
+ # Offer to generate CSR automatically
796
+ if yes_with_default?("Generate CSR and get step-by-step guide?", :green)
797
+ say ""
798
+ csr_path = generate_csr(config.user_email)
799
+
800
+ if csr_path
801
+ say ""
802
+ say " ✓ CSR ready: #{File.basename(csr_path)}", :green
803
+ say ""
804
+ say " 📋 Next steps:", :cyan
805
+ say " 1. Go to: https://developer.apple.com/account/resources/certificates/add", :green
806
+ say " 2. Select: '#{cert_name}'", :green
807
+ say " 3. Upload CSR: #{csr_path}", :green
808
+ say " 4. Download .cer file and double-click to install", :green
809
+ say " 5. Sync in My Signer → Run 'mysigner doctor' again", :green
810
+ say ""
811
+ end
812
+ else
813
+ say ""
814
+ say " 📋 Quick guide:", :cyan
815
+ say " 1. Open Keychain Access → Request Certificate (save as CSR)", :green
816
+ say " 2. https://developer.apple.com/account/resources/certificates/add", :green
817
+ say " 3. Select '#{cert_name}' → Upload CSR → Download .cer", :green
818
+ say " 4. Double-click .cer to install → Sync My Signer", :green
819
+ say ""
820
+ end
821
+ elsif error_msg.include?("no_devices") || error_msg.include?("devices found")
822
+ say " ✗ No test devices (needed for dev profiles)", :red
823
+ say ""
824
+ say " 📋 Quick fix:", :cyan
825
+ say " • Get UDID: Connect device → Finder → Click serial number", :green
826
+ say " • Run: mysigner device add <UDID> <NAME>", :green
827
+ say " • Or add in: #{client.api_url}/organizations/#{config.current_organization_id}", :green
828
+ say ""
829
+ else
830
+ say " ✗ Failed: #{error_msg}", :red
831
+ end
832
+ say ""
833
+ false
834
+ rescue => e
835
+ say " ✗ Unexpected error: #{e.message}", :red
836
+ say ""
837
+ false
838
+ end
839
+ end
840
+ end
841
+
842
+ desc "sync [PLATFORM]", "🔄 Sync data from App Store Connect or Google Play"
843
+ long_desc <<~DESC
844
+ Sync your organization's data from app stores.
845
+
846
+ PLATFORMS (optional):
847
+ ios : Sync from App Store Connect (default)
848
+ android : Sync from Google Play
849
+ all : Sync from both platforms
850
+
851
+ Without a platform argument, syncs iOS (App Store Connect) data.
852
+
853
+ EXAMPLES:
854
+ mysigner sync # Sync iOS data
855
+ mysigner sync ios # Sync iOS data
856
+ mysigner sync android # Sync Android data
857
+ mysigner sync all # Sync both platforms
858
+ DESC
859
+ option :force, type: :boolean, aliases: '-f', desc: 'Force sync even if recently synced'
860
+ def sync(platform = 'ios')
861
+ config = load_config
862
+ client = create_client(config)
863
+
864
+ platform = platform.to_s.downcase
865
+
866
+ case platform
867
+ when 'ios', 'apple', 'appstore'
868
+ sync_ios(client, config)
869
+ when 'android', 'google', 'googleplay', 'play'
870
+ sync_android(client, config)
871
+ when 'all', 'both'
872
+ sync_ios(client, config)
873
+ say ""
874
+ sync_android(client, config)
875
+ else
876
+ error "Unknown platform: #{platform}"
877
+ say "Valid platforms: ios, android, all", :yellow
878
+ exit 1
879
+ end
880
+ end
881
+
882
+ no_commands do
883
+ def sync_ios(client, config)
884
+ say "🔄 Syncing data from App Store Connect...", :cyan
885
+ say ""
886
+
887
+ begin
888
+ response = client.post(
889
+ "/api/v1/organizations/#{config.current_organization_id}/sync",
890
+ body: { force: options[:force] }
891
+ )
892
+
893
+ if response[:success]
894
+ data = response[:data]
895
+ say "✓ iOS sync completed!", :green
896
+ say ""
897
+
898
+ if data['synced_at']
899
+ say "Last synced: #{data['synced_at']}", :cyan
900
+ end
901
+
902
+ if data['summary']
903
+ say ""
904
+ say "📊 Summary:", :cyan
905
+ summary = data['summary']
906
+ say " • Apps: #{summary['apps']}" if summary['apps']
907
+ say " • Builds: #{summary['builds']}" if summary['builds']
908
+ say " • Certificates: #{summary['certificates']}" if summary['certificates']
909
+ say " • Devices: #{summary['devices']}" if summary['devices']
910
+ say " • Profiles: #{summary['profiles']}" if summary['profiles']
911
+ end
912
+ else
913
+ say "✗ iOS sync failed: #{response[:error]}", :red
914
+ end
915
+ rescue => e
916
+ say "✗ iOS sync failed: #{e.message}", :red
917
+ end
918
+ end
919
+
920
+ def sync_android(client, config)
921
+ say "🔄 Syncing data from Google Play...", :cyan
922
+ say ""
923
+
924
+ begin
925
+ response = client.post(
926
+ "/api/v1/organizations/#{config.current_organization_id}/sync_google_play",
927
+ body: { force: options[:force] }
928
+ )
929
+
930
+ if response[:success]
931
+ say "✓ Android sync started!", :green
932
+ say ""
933
+ say "Sync runs in the background. Check status with:", :cyan
934
+ say " mysigner android-apps", :green
935
+ say ""
936
+
937
+ # Optionally wait and show status
938
+ say "💡 Google Play sync may take a few minutes.", :yellow
939
+ say " Unlike iOS, Google Play doesn't auto-discover apps.", :yellow
940
+ say " If no apps appear, add them in the web dashboard first.", :yellow
941
+ else
942
+ say "✗ Android sync failed: #{response[:error] || 'Unknown error'}", :red
943
+ end
944
+ rescue Mysigner::ClientError => e
945
+ if e.message.include?("No active Google Play credential")
946
+ say "✗ Google Play not configured", :red
947
+ say ""
948
+ say "Set up credentials first:", :yellow
949
+ say " Configure Google Play in My Signer dashboard", :green
950
+ else
951
+ say "✗ Android sync failed: #{e.message}", :red
952
+ end
953
+ rescue => e
954
+ say "✗ Android sync failed: #{e.message}", :red
955
+ end
956
+ end
957
+
958
+ # Detect valid JAVA_HOME using macOS java_home utility or common paths
959
+ def detect_java_home(version: nil)
960
+ # Try macOS java_home utility first (most reliable)
961
+ if system('which /usr/libexec/java_home > /dev/null 2>&1')
962
+ cmd = '/usr/libexec/java_home'
963
+ cmd += " -v #{version}" if version
964
+ java_home = `#{cmd} 2>/dev/null`.strip
965
+ return java_home if !java_home.empty? && Dir.exist?(java_home)
966
+ end
967
+
968
+ # Try common Homebrew paths (Apple Silicon)
969
+ homebrew_paths = %w[
970
+ /opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
971
+ /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
972
+ /opt/homebrew/opt/openjdk/libexec/openjdk.jdk/Contents/Home
973
+ ]
974
+ homebrew_paths.each do |path|
975
+ return path if Dir.exist?(path)
976
+ end
977
+
978
+ # Try common Homebrew paths (Intel)
979
+ intel_paths = %w[
980
+ /usr/local/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
981
+ /usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
982
+ /usr/local/opt/openjdk/libexec/openjdk.jdk/Contents/Home
983
+ ]
984
+ intel_paths.each do |path|
985
+ return path if Dir.exist?(path)
986
+ end
987
+
988
+ # Try system Java
989
+ system_paths = Dir.glob('/Library/Java/JavaVirtualMachines/*/Contents/Home')
990
+ return system_paths.first if system_paths.any?
991
+
992
+ nil
993
+ end
994
+
995
+ # Fix JAVA_HOME in shell config
996
+ def fix_java_home(java_home)
997
+ shell_config = File.expand_path('~/.zshrc')
998
+
999
+ # Use ~/.bash_profile if zsh config doesn't exist
1000
+ unless File.exist?(shell_config)
1001
+ shell_config = File.expand_path('~/.bash_profile')
1002
+ end
1003
+
1004
+ # Read existing content
1005
+ content = File.exist?(shell_config) ? File.read(shell_config) : ""
1006
+
1007
+ # Check if JAVA_HOME is already set
1008
+ if content.include?('export JAVA_HOME=')
1009
+ # Replace existing JAVA_HOME line
1010
+ new_content = content.gsub(/^export JAVA_HOME=.*$/, "export JAVA_HOME=\"#{java_home}\"")
1011
+ File.write(shell_config, new_content)
1012
+ say " ✓ Updated JAVA_HOME in #{shell_config}", :green
1013
+ else
1014
+ # Append JAVA_HOME
1015
+ File.open(shell_config, 'a') do |f|
1016
+ f.puts ""
1017
+ f.puts "# Added by mysigner doctor"
1018
+ f.puts "export JAVA_HOME=\"#{java_home}\""
1019
+ end
1020
+ say " ✓ Added JAVA_HOME to #{shell_config}", :green
1021
+ end
1022
+
1023
+ say ""
1024
+ say " To apply now, run:", :yellow
1025
+ say " source #{shell_config}", :cyan
1026
+ say ""
1027
+ say " Or restart your terminal.", :yellow
1028
+ end
1029
+ end
1030
+ end
1031
+ end
1032
+ end
1033
+ end
1034
+ end