mysigner 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'stringio' # discover_local_*_silently use StringIO as a non-tty stderr proxy
4
+
3
5
  module Mysigner
4
6
  class CLI < Thor
5
7
  module AuthCommands
@@ -28,6 +30,10 @@ module Mysigner
28
30
 
29
31
  New user? Run 'mysigner onboard' for step-by-step guidance.
30
32
 
33
+ No My Signer account? You don't need one to sign and ship — run
34
+ 'mysigner --local-only onboard' to use your own Apple/Google
35
+ credentials, kept on this machine.
36
+
31
37
  Your credentials will be stored securely in ~/.mysigner/config.yml
32
38
 
33
39
  Note: API tokens are organization-specific. This token will only
@@ -232,6 +238,26 @@ module Mysigner
232
238
 
233
239
  # Check if already configured
234
240
  config = Config.new
241
+
242
+ # Fresh user, interactive: offer the two modes up front so a newcomer
243
+ # doesn't assume a My Signer website account is mandatory.
244
+ if $stdin.tty? && !(config.exists? && config.current_organization_id)
245
+ say 'How do you want to use My Signer?', :cyan
246
+ say ''
247
+ say ' 1. With a free My Signer account', :white
248
+ say ' (keys stored on the server; sync across machines & team)'
249
+ say ' 2. Local-only — no account', :white
250
+ say ' (your Apple/Google keys stay on THIS machine; nothing sent to a server)'
251
+ say ''
252
+ say 'Not sure? Choose 2 (local-only).', :yellow
253
+ say ''
254
+ if ask('Select (1-2):', limited_to: %w[1 2]) == '2'
255
+ emit_local_only_banner
256
+ return onboard_local_only
257
+ end
258
+ say ''
259
+ end
260
+
235
261
  if config.exists? && config.api_token && config.current_organization_id
236
262
  say "✓ You're already logged in!", :green
237
263
  say ''
@@ -540,6 +566,19 @@ module Mysigner
540
566
  end
541
567
  end
542
568
 
569
+ # Android: offer Google Play setup too. Previously onboard only
570
+ # ever configured App Store Connect, leaving Android users without
571
+ # a guided path even though setup_google_play_credentials existed.
572
+ # Only prompt interactively — in CI/non-tty contexts onboarding is
573
+ # driven by env vars/flags, not the wizard.
574
+ gp_configured = false
575
+ if $stdin.tty? && yes_with_default?('Set up Google Play (Android) credentials now?', :cyan)
576
+ say ''
577
+ gp_configured = setup_google_play_credentials(client, config, org_id)
578
+ elsif $stdin.tty?
579
+ say '⏭️ Skipped Google Play setup (run `mysigner onboard` again anytime)', :yellow
580
+ end
581
+
543
582
  say ''
544
583
  say '=' * 80, :green
545
584
  say '🎉 Setup Complete!', :green
@@ -564,6 +603,13 @@ module Mysigner
564
603
  say " Run 'mysigner doctor' to set it up", :yellow
565
604
  end
566
605
 
606
+ if gp_configured
607
+ say '✓ Google Play: Configured', :green
608
+ else
609
+ say '⚠️ Google Play: Not configured', :yellow
610
+ say " Run 'mysigner onboard' to set it up", :yellow
611
+ end
612
+
567
613
  say ''
568
614
  say '🔒 Security Note:', :yellow
569
615
  say " Your token is organization-specific. Use 'mysigner switch'", :yellow
@@ -1027,9 +1073,12 @@ module Mysigner
1027
1073
  def config(action = nil, *args)
1028
1074
  return config_set(*args) if action == 'set'
1029
1075
 
1030
- if action && action != 'set'
1076
+ # `show` is the documented alias for the bare display (README +
1077
+ # `config [ACTION]` help). Treat nil/`show` as display; anything
1078
+ # else is a genuine typo.
1079
+ if action && !%w[show].include?(action)
1031
1080
  error "Unknown config action: #{action}"
1032
- say 'Did you mean: `mysigner config set <key> <value>`?', :yellow
1081
+ say 'Did you mean: `mysigner config show` or `mysigner config set <key> <value>`?', :yellow
1033
1082
  exit 1
1034
1083
  end
1035
1084
 
@@ -1679,23 +1728,35 @@ module Mysigner
1679
1728
  say ''
1680
1729
  stored_play = collect_local_google_play_credential
1681
1730
  end
1731
+ say ''
1732
+
1733
+ # Android signing keystore (the actual signing key). Stored locally
1734
+ # in the exact envelope CredentialResolver#resolve_android_keystore
1735
+ # expects, so `ship --platform android --local-only` can sign offline.
1736
+ stored_keystore = []
1737
+ if $stdin.tty? && yes_with_default?('Set up an Android signing keystore now?', :cyan)
1738
+ say ''
1739
+ stored_keystore = collect_local_keystore_credential
1740
+ end
1682
1741
 
1683
1742
  say ''
1684
1743
  say '=' * 80, :green
1685
1744
  say '✓ Local-only setup complete.', :green
1686
1745
  say '=' * 80, :green
1687
1746
  say ''
1688
- if stored_asc.empty? && stored_play.empty?
1747
+ if stored_asc.empty? && stored_play.empty? && stored_keystore.empty?
1689
1748
  say 'No credentials were stored.', :yellow
1690
1749
  say "Re-run 'mysigner --local-only onboard' when you're ready.", :yellow
1691
1750
  else
1692
1751
  say 'Stored credentials:', :cyan
1693
- stored_asc.each { |id| say " • ASC key: #{id}" }
1694
- stored_play.each { |id| say " • Google Play SA: #{id}" }
1752
+ stored_asc.each { |id| say " • ASC key: #{id}" }
1753
+ stored_play.each { |id| say " • Google Play SA: #{id}" }
1754
+ stored_keystore.each { |id| say " • Android keystore: #{id}" }
1695
1755
  say ''
1696
1756
  say 'To ship:', :bold
1697
1757
  say ' mysigner --local-only ship appstore' unless stored_asc.empty?
1698
1758
  say ' mysigner --local-only ship play' unless stored_play.empty?
1759
+ say ' mysigner --local-only ship internal --platform android' unless stored_keystore.empty?
1699
1760
  end
1700
1761
  say ''
1701
1762
  end
@@ -1721,8 +1782,14 @@ module Mysigner
1721
1782
  key_id = ask('Enter your Key ID (e.g., ABC12345):').to_s.strip if key_id.nil? || key_id.empty?
1722
1783
  raise_local_onboard_error!('Key ID cannot be empty') if key_id.empty?
1723
1784
 
1785
+ say 'Find your Issuer ID at https://appstoreconnect.apple.com/access/api', :cyan
1786
+ say '(the UUID shown at the top of the Keys page).', :cyan
1724
1787
  issuer_id = ask('Enter your Issuer ID (UUID):').to_s.strip
1725
- raise_local_onboard_error!('Issuer ID cannot be empty') if issuer_id.empty?
1788
+ if issuer_id.empty?
1789
+ raise_local_onboard_error!(
1790
+ "Issuer ID cannot be empty (it's the UUID at appstoreconnect.apple.com/access/api)"
1791
+ )
1792
+ end
1726
1793
 
1727
1794
  # Storage shape matches mysigner-42's Option A: id == key_id,
1728
1795
  # secret is a JSON envelope so AscJwtMinter can reconstruct
@@ -1753,6 +1820,42 @@ module Mysigner
1753
1820
  [client_email]
1754
1821
  end
1755
1822
 
1823
+ # Returns Array<String> of ids actually stored (empty on skip).
1824
+ # Persists the keystore bytes (base64) + passwords + alias under
1825
+ # LocalCredentials(kind: :android_keystore) in the exact envelope
1826
+ # CredentialResolver#resolve_android_keystore reads back.
1827
+ def collect_local_keystore_credential
1828
+ require 'base64'
1829
+ say '🔑 Android keystore (local-only)', :cyan
1830
+ say ''
1831
+
1832
+ ks_path = ask('Path to your keystore (.jks/.keystore):').to_s.strip.gsub(/^['"]|['"]$/, '')
1833
+ ks_path = File.expand_path(ks_path)
1834
+ raise_local_onboard_error!("Keystore file not found: #{ks_path}") unless File.exist?(ks_path)
1835
+
1836
+ # echo: false — keep secrets out of terminal scrollback, matching
1837
+ # every other secret prompt in this file.
1838
+ store_password = ask('Keystore password:', echo: false).to_s
1839
+ raise_local_onboard_error!('Keystore password cannot be empty') if store_password.empty?
1840
+
1841
+ key_alias = ask('Key alias:').to_s.strip
1842
+ raise_local_onboard_error!('Key alias cannot be empty') if key_alias.empty?
1843
+
1844
+ key_password = ask('Key password (press Enter to reuse the keystore password):', echo: false).to_s
1845
+ key_password = store_password if key_password.empty?
1846
+
1847
+ envelope = JSON.generate(
1848
+ 'keystore_b64' => Base64.strict_encode64(File.binread(ks_path)),
1849
+ 'keystore_password' => store_password,
1850
+ 'key_alias' => key_alias,
1851
+ 'key_password' => key_password
1852
+ )
1853
+ Mysigner::LocalCredentials.store(kind: :android_keystore, id: key_alias, secret: envelope)
1854
+
1855
+ say "✓ Android keystore stored locally (alias: #{key_alias}).", :green
1856
+ [key_alias]
1857
+ end
1858
+
1756
1859
  # Verifies the file looks like an EC private key in the form
1757
1860
  # AscJwtMinter requires. Fails loud — any malformed input raises
1758
1861
  # before we touch the Keychain.
@@ -20,15 +20,18 @@ module Mysigner
20
20
  long_desc <<~DESC
21
21
  Build your project, sign it, and upload in one go.
22
22
 
23
- iOS TARGETS
24
- testflight : Upload a beta build to TestFlight
25
- appstore : Upload a production build to App Store Connect
23
+ iOS TARGETS (requires macOS + Xcode)
24
+ testflight : Beta testing via TestFlight
25
+ appstore : Submit for public App Store release
26
26
 
27
- ANDROID TARGETS
28
- internal : Upload to internal testing track
29
- alpha : Upload to alpha (closed testing) track
30
- beta : Upload to beta (open testing) track
31
- production : Upload to production track
27
+ ANDROID TARGETS (works on macOS, Linux, and Windows)
28
+ internal : Fastest — up to 100 testers you list. NOT public.
29
+ alpha : Closed testing an invite-only tester group.
30
+ beta : Open testing anyone with your opt-in link.
31
+ production : PUBLIC — goes LIVE to everyone on the Google Play Store.
32
+
33
+ An AAB (Android App Bundle, .aab) is what Google Play requires — the
34
+ CLI builds and signs it for you (you don't upload an APK).
32
35
 
33
36
  PLATFORM OPTIONS
34
37
  --platform ios Force iOS build (auto-detected by default)
@@ -43,6 +46,14 @@ module Mysigner
43
46
  --release-notes TEXT Release notes for Play Store
44
47
  --package-name PKG Override the detected package name
45
48
 
49
+ LOCAL-ONLY (no My Signer account — use your OWN credentials)
50
+ Run `mysigner --local-only onboard` once for a guided setup, or pass:
51
+ iOS: --asc-key-path (the AuthKey_XXXX.p8 from
52
+ appstoreconnect.apple.com/access/api), --asc-key-id (the
53
+ XXXX in that filename), --asc-issuer-id (the UUID on that page)
54
+ Android: --keystore-path / --keystore-password / --key-alias and
55
+ --play-credentials (a Google Play service-account .json)
56
+
46
57
  WORKFLOW
47
58
  For iOS TestFlight:
48
59
  mysigner ship testflight # Build → Upload → Done!
@@ -140,7 +151,8 @@ module Mysigner
140
151
  return
141
152
  end
142
153
 
143
- # iOS flow continues below...
154
+ # iOS flow continues below — requires macOS + Xcode.
155
+ require_macos!("ship #{target}")
144
156
 
145
157
  is_appstore = (target == 'appstore')
146
158
 
@@ -1817,6 +1829,7 @@ module Mysigner
1817
1829
  method_option :skip_extensions, type: :boolean, default: false,
1818
1830
  desc: 'Skip extension targets (useful when extensions are not configured)'
1819
1831
  def build
1832
+ require_macos!('build')
1820
1833
  config = load_config
1821
1834
  client = create_client(config)
1822
1835
 
@@ -2031,6 +2044,7 @@ module Mysigner
2031
2044
  desc: 'Export method (appstore, adhoc, enterprise, development)'
2032
2045
  method_option :output, type: :string, desc: 'Output directory for .ipa file'
2033
2046
  def export(archive_path)
2047
+ require_macos!('export')
2034
2048
  load_config
2035
2049
 
2036
2050
  begin
@@ -2085,6 +2099,7 @@ module Mysigner
2085
2099
  "Upload existing .ipa to TestFlight (advanced - most users should use 'ship')"
2086
2100
  method_option :wait, type: :boolean, default: false, desc: 'Wait for processing to complete'
2087
2101
  def upload(target, ipa_path)
2102
+ require_macos!('upload testflight')
2088
2103
  unless target == 'testflight'
2089
2104
  error "Only 'testflight' target is supported currently"
2090
2105
  say 'Usage: mysigner upload testflight IPA_PATH', :yellow
@@ -84,19 +84,16 @@ module Mysigner
84
84
  say ''
85
85
  say 'To fix this:', :cyan
86
86
  say ''
87
- if api_url.include?('localhost')
88
- say ' For local development:', :bold
89
- say ' 1. Make sure Rails server is running:'
90
- say ' cd path/to/my-signer'
91
- say ' bin/rails server'
92
- say ''
93
- say " 2. Verify it's accessible:"
94
- say " curl #{api_url}/up"
87
+ if api_url.include?('localhost') && ENV['MYSIGNER_DEV']
88
+ say ' For My Signer backend development:', :bold
89
+ say ' 1. Start the Rails server: cd path/to/my-signer && bin/rails server'
90
+ say " 2. Verify it's accessible: curl #{api_url}/up"
95
91
  else
96
- say ' For production:', :bold
97
- say ' 1. Check the API URL is correct'
98
- say ' 2. Verify the service is running'
99
- say ' 3. Check your internet connection'
92
+ say ' To fix:', :bold
93
+ say ' 1. Check your internet connection'
94
+ say ' 2. Verify the API URL is correct: mysigner config show'
95
+ say " 3. Don't need a My Signer account? Run any command with --local-only"
96
+ say ' to sign with your own Apple/Google credentials.'
100
97
  end
101
98
  say ''
102
99
  say ' Or set a custom API URL:', :bold
@@ -102,11 +102,27 @@ module Mysigner
102
102
  say ' (auto-discovers ASC .p8 from ~/.appstoreconnect/private_keys/,', :yellow
103
103
  say ' Google Play SA-JSON from GOOGLE_APPLICATION_CREDENTIALS / eas.json,', :yellow
104
104
  say ' keystore from key.properties / eas.json — or set them via flags / env.)', :yellow
105
+ say ' Note: build / ship / sign work fully local; account commands', :yellow
106
+ say ' (orgs / switch / sync) still need a My Signer login.', :yellow
105
107
  say ' See "Local-only mode" section in README.', :yellow
106
108
  exit 1
107
109
  end
108
110
 
109
111
  config.load
112
+
113
+ # Surface an unreadable stored token as a clean re-login prompt here,
114
+ # at the auth gate, instead of letting the decrypt error explode later
115
+ # inside create_client / Config#display. (api_token decrypts lazily.)
116
+ begin
117
+ config.api_token
118
+ rescue Mysigner::ConfigError
119
+ error 'Your saved login is unreadable (encryption key changed or ' \
120
+ 'config copied between machines).'
121
+ say "Run 'mysigner logout' then 'mysigner login' to re-authenticate.", :yellow
122
+ say 'Or run with --local-only to skip MySigner entirely.', :yellow
123
+ exit 1
124
+ end
125
+
110
126
  config
111
127
  end
112
128
 
@@ -150,6 +166,27 @@ module Mysigner
150
166
  say "✗ Error: #{message}", :red
151
167
  end
152
168
 
169
+ # True on macOS. iOS building/signing (Xcode, xcodebuild, the keychain)
170
+ # only works there; iOS-only commands call require_macos! to fail with a
171
+ # clear message instead of a raw "xcodebuild: not found" backtrace.
172
+ def macos?
173
+ !(RbConfig::CONFIG['host_os'] =~ /darwin/i).nil?
174
+ end
175
+
176
+ # Guard for iOS-only commands. On non-macOS, explain plainly and point
177
+ # the user at the Android path that DOES work cross-platform, then exit.
178
+ def require_macos!(command_label = 'This command')
179
+ return if macos?
180
+
181
+ error "#{command_label} requires macOS with Xcode."
182
+ say ''
183
+ say 'iOS building, signing, and uploading only work on a Mac (they use Xcode).', :yellow
184
+ say 'On Linux or Windows you can still build and ship Android:', :yellow
185
+ say ' mysigner ship internal --platform android', :cyan
186
+ say ' mysigner android build', :cyan
187
+ exit 1
188
+ end
189
+
153
190
  # Local-only mode is active when any of, in precedence order:
154
191
  # 1. --local-only / --no-local-only flag on this invocation
155
192
  # 2. MYSIGNER_LOCAL_ONLY env var
@@ -18,8 +18,6 @@ module Mysigner
18
18
 
19
19
  # Determine which platforms to check
20
20
  platform_filter = options[:platform]&.downcase
21
- check_ios = platform_filter.nil? || platform_filter == 'all' || platform_filter == 'ios'
22
- check_android = platform_filter.nil? || platform_filter == 'all' || platform_filter == 'android'
23
21
 
24
22
  if platform_filter && !%w[ios android all].include?(platform_filter)
25
23
  error "Invalid platform: #{platform_filter}"
@@ -27,6 +25,18 @@ module Mysigner
27
25
  exit 1
28
26
  end
29
27
 
28
+ want_ios = platform_filter.nil? || platform_filter == 'all' || platform_filter == 'ios'
29
+ # iOS checks only mean something on macOS. On Linux/Windows, skip them
30
+ # with one info line instead of a wall of red "Xcode not found" issues —
31
+ # unless the user EXPLICITLY asked for --platform ios.
32
+ check_ios = want_ios && (macos? || platform_filter == 'ios')
33
+ check_android = platform_filter.nil? || platform_filter == 'all' || platform_filter == 'android'
34
+
35
+ if want_ios && !check_ios
36
+ say 'ℹ️ iOS checks skipped — iOS building requires macOS + Xcode.', :cyan
37
+ say ''
38
+ end
39
+
30
40
  # Check 1: Xcode (iOS only)
31
41
  if check_ios
32
42
  say 'Checking Xcode...', :yellow
@@ -110,6 +120,12 @@ module Mysigner
110
120
  say ' ✗ Token is invalid or expired', :red
111
121
  issues << "Token authentication failed - run 'mysigner onboard' to re-authenticate"
112
122
  client = nil
123
+ rescue Mysigner::ConfigError
124
+ # An undecryptable stored token is a LOCAL credential problem,
125
+ # not an API/network failure — say so and point at re-login.
126
+ say ' ✗ Saved credentials unreadable (encryption key changed or config corrupt)', :red
127
+ issues << "Stored login unreadable - run 'mysigner logout' then 'mysigner login'"
128
+ client = nil
113
129
  rescue Mysigner::ConnectionError => e
114
130
  say " ✗ Cannot connect to API: #{e.message}", :red
115
131
  issues << 'API connection failed - check your network or API URL'
@@ -253,18 +269,24 @@ module Mysigner
253
269
  end
254
270
  say ''
255
271
 
256
- # Check 8: Project Detection (if in a project directory)
272
+ # Check 8: Project Detection (read-only must NEVER trigger an
273
+ # expo prebuild from a diagnostic, hence allow_prebuild: false).
257
274
  say 'Checking current directory...', :yellow
258
275
  project_info = nil
259
276
  begin
260
- project_info = Build::Detector.detect
261
- framework = case project_info[:framework]
262
- when :capacitor then 'Capacitor/Ionic'
263
- when :react_native then 'React Native'
264
- when :flutter then 'Flutter'
265
- else 'Native iOS'
266
- end
267
- say " ✓ Found #{framework} project: #{File.basename(project_info[:path])}", :green
277
+ project_info = Build::Detector.detect(allow_prebuild: false)
278
+ if project_info[:needs_prebuild]
279
+ say ' ℹ️ Expo managed project detected (no native ios/ folder yet)', :cyan
280
+ say ' Generate it with: mysigner ship (or `npx expo prebuild`)', :cyan
281
+ else
282
+ framework = case project_info[:framework]
283
+ when :capacitor then 'Capacitor/Ionic'
284
+ when :react_native then 'React Native'
285
+ when :flutter then 'Flutter'
286
+ else 'Native iOS'
287
+ end
288
+ say " ✓ Found #{framework} project: #{File.basename(project_info[:path])}", :green
289
+ end
268
290
  rescue StandardError
269
291
  say ' ℹ️ No project detected in current directory', :cyan
270
292
  end
@@ -516,6 +538,12 @@ module Mysigner
516
538
  say ' ⚠️ JAVA_HOME not set', :yellow
517
539
  end
518
540
  end
541
+ elsif platform_filter == 'android'
542
+ # When the user explicitly asks to check Android, a missing JDK
543
+ # is a hard blocker, not an FYI — otherwise doctor green-lights an
544
+ # environment that cannot build.
545
+ say ' ✗ Java not found (required for Android)', :red
546
+ issues << 'Java (JDK) not found — required to build Android. Install JDK 17+ and set JAVA_HOME.'
519
547
  else
520
548
  say ' ℹ️ Java not found (required for Android)', :cyan
521
549
  end
@@ -525,6 +553,9 @@ module Mysigner
525
553
  if android_home && Dir.exist?(android_home)
526
554
  say " ✓ Android SDK: #{android_home}", :green
527
555
  android_available = true
556
+ elsif platform_filter == 'android'
557
+ say ' ✗ Android SDK not found (set ANDROID_HOME)', :red
558
+ issues << 'Android SDK not found — set ANDROID_HOME to your SDK location.'
528
559
  else
529
560
  say ' ℹ️ Android SDK not found (set ANDROID_HOME)', :cyan
530
561
  end
@@ -581,25 +612,32 @@ module Mysigner
581
612
  say ''
582
613
  end
583
614
 
584
- # Check 15: Android Project Detection
585
- nil
615
+ # Check 15: Android Project Detection (read-only — must NEVER
616
+ # trigger an expo prebuild from a diagnostic, hence allow_prebuild:
617
+ # false).
586
618
  begin
587
- android_project = Build::Detector.detect_android
588
- framework = case android_project[:framework]
589
- when :capacitor then 'Capacitor/Ionic'
590
- when :react_native then 'React Native'
591
- when :flutter then 'Flutter'
592
- else 'Native Android'
593
- end
619
+ android_project = Build::Detector.detect_android(allow_prebuild: false)
594
620
  say 'Checking Android project...', :yellow
595
- say " ✓ Found #{framework} Android project", :green
596
-
597
- # Parse project details
598
- require_relative '../build/android_parser'
599
- parser = Build::AndroidParser.new(android_project)
600
- say " Package: #{parser.application_id}", :cyan
601
- say " Version: #{parser.version_name} (#{parser.version_code})", :cyan
602
- say " Gradle wrapper: #{parser.gradle_wrapper_exists? ? '✓' : '✗'}", :cyan
621
+ if android_project[:needs_prebuild]
622
+ say ' ℹ️ Expo managed project detected (no android/ folder yet)', :cyan
623
+ say ' Generate it with: mysigner android build', :cyan
624
+ say ' (needs Node >= 20.19.4 and `npm install` first)', :cyan
625
+ else
626
+ framework = case android_project[:framework]
627
+ when :capacitor then 'Capacitor/Ionic'
628
+ when :react_native then 'React Native'
629
+ when :flutter then 'Flutter'
630
+ else 'Native Android'
631
+ end
632
+ say " ✓ Found #{framework} Android project", :green
633
+
634
+ # Parse project details
635
+ require_relative '../build/android_parser'
636
+ parser = Build::AndroidParser.new(android_project)
637
+ say " Package: #{parser.application_id}", :cyan
638
+ say " Version: #{parser.version_name} (#{parser.version_code})", :cyan
639
+ say " Gradle wrapper: #{parser.gradle_wrapper_exists? ? '✓' : '✗'}", :cyan
640
+ end
603
641
  say ''
604
642
  rescue Build::Detector::NoProjectError
605
643
  # Not an Android project, that's fine
@@ -617,7 +655,7 @@ module Mysigner
617
655
  if issues.empty? && warnings.empty?
618
656
  say "🎉 All checks passed! You're good to go!", :green
619
657
  say ''
620
- say 'Try: mysigner ship testflight', :cyan
658
+ say(platform_filter == 'android' ? 'Try: mysigner ship internal --platform android' : 'Try: mysigner ship testflight', :cyan)
621
659
  elsif issues.empty?
622
660
  say "⚠️ #{warnings.length} warning(s), but you're mostly good!", :yellow
623
661
  say ''