mysigner 0.1.2 → 0.1.4

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.githooks/pre-commit +15 -0
  3. data/.githooks/pre-push +21 -0
  4. data/.github/workflows/ci.yml +29 -0
  5. data/.gitignore +4 -0
  6. data/.rubocop.yml +55 -0
  7. data/.rubocop_todo.yml +126 -0
  8. data/CHANGELOG.md +96 -0
  9. data/Gemfile +5 -3
  10. data/Gemfile.lock +38 -8
  11. data/README.md +14 -16
  12. data/Rakefile +5 -3
  13. data/bin/console +4 -3
  14. data/bin/setup +3 -0
  15. data/certificate_.cer +0 -0
  16. data/exe/mysigner +19 -2
  17. data/iOS_App_Store_Profile.mobileprovision +1 -0
  18. data/iOS_Distribution_Certificate.cer +1 -0
  19. data/lib/mysigner/build/android_executor.rb +83 -63
  20. data/lib/mysigner/build/android_parser.rb +33 -40
  21. data/lib/mysigner/build/configurator.rb +17 -16
  22. data/lib/mysigner/build/detector.rb +39 -50
  23. data/lib/mysigner/build/error_analyzer.rb +70 -68
  24. data/lib/mysigner/build/executor.rb +30 -37
  25. data/lib/mysigner/build/parser.rb +18 -18
  26. data/lib/mysigner/cleanup/private_keys_purger.rb +41 -0
  27. data/lib/mysigner/cli/auth_commands.rb +771 -764
  28. data/lib/mysigner/cli/build_commands.rb +962 -796
  29. data/lib/mysigner/cli/concerns/actionable_suggestions.rb +208 -154
  30. data/lib/mysigner/cli/concerns/api_helpers.rb +46 -54
  31. data/lib/mysigner/cli/concerns/error_handlers.rb +247 -237
  32. data/lib/mysigner/cli/concerns/helpers.rb +44 -1
  33. data/lib/mysigner/cli/diagnostic_commands.rb +667 -636
  34. data/lib/mysigner/cli/resource_commands.rb +1153 -985
  35. data/lib/mysigner/cli/validate_commands.rb +25 -25
  36. data/lib/mysigner/cli.rb +11 -1
  37. data/lib/mysigner/client.rb +27 -19
  38. data/lib/mysigner/config.rb +161 -60
  39. data/lib/mysigner/export/exporter.rb +38 -37
  40. data/lib/mysigner/signing/certificate_checker.rb +18 -23
  41. data/lib/mysigner/signing/gradle_signing_injector.rb +67 -0
  42. data/lib/mysigner/signing/keystore_manager.rb +81 -61
  43. data/lib/mysigner/signing/validator.rb +38 -40
  44. data/lib/mysigner/signing/wizard.rb +329 -342
  45. data/lib/mysigner/upload/app_store_automation.rb +96 -49
  46. data/lib/mysigner/upload/app_store_submission.rb +87 -92
  47. data/lib/mysigner/upload/asc_rest_uploader.rb +119 -0
  48. data/lib/mysigner/upload/play_store_uploader.rb +164 -144
  49. data/lib/mysigner/upload/uploader.rb +136 -115
  50. data/lib/mysigner/version.rb +3 -1
  51. data/lib/mysigner.rb +13 -11
  52. data/mysigner.gemspec +36 -33
  53. data/profile_.mobileprovision +0 -0
  54. data/test_manual.rb +37 -36
  55. metadata +44 -17
  56. data/.DS_Store +0 -0
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'faraday'
5
+ require 'uri'
6
+
7
+ module Mysigner
8
+ module Upload
9
+ class AscRestUploader
10
+ # Raised when Apple rejects the /v1/buildUploads POST with a 409
11
+ # (duplicate CFBundleVersion). Callers should translate this into a
12
+ # "bump your build number" hint rather than a generic "Unexpected error".
13
+ class BuildVersionConflictError < StandardError; end
14
+
15
+ TERMINAL_APPLE_STATES = %w[COMPLETE FAILED INVALIDATED].freeze
16
+ POLL_INTERVAL = 10
17
+ POLL_TIMEOUT = 600
18
+ CHUNK_RETRIES = 2
19
+
20
+ def initialize(client:, organization_id:, ipa_path:, apple_app_id:,
21
+ cf_bundle_version:, cf_bundle_short_version_string:,
22
+ platform: 'IOS', poll_interval: POLL_INTERVAL, poll_timeout: POLL_TIMEOUT)
23
+ @client = client
24
+ @org_id = organization_id
25
+ @ipa_path = ipa_path
26
+ @apple_app_id = apple_app_id
27
+ @cf_bundle_version = cf_bundle_version
28
+ @cf_bundle_short_version_string = cf_bundle_short_version_string
29
+ @platform = platform
30
+ @poll_interval = poll_interval
31
+ @poll_timeout = poll_timeout
32
+ end
33
+
34
+ def call
35
+ begin
36
+ resp = @client.post(
37
+ "/api/v1/organizations/#{@org_id}/builds/asc_upload",
38
+ body: {
39
+ apple_app_id: @apple_app_id,
40
+ cf_bundle_version: @cf_bundle_version,
41
+ cf_bundle_short_version_string: @cf_bundle_short_version_string,
42
+ platform: @platform,
43
+ file_name: File.basename(@ipa_path),
44
+ file_size: File.size(@ipa_path)
45
+ }
46
+ )
47
+ rescue StandardError => e
48
+ # Apple returns 409 from /v1/buildUploads when a build with the
49
+ # same CFBundleVersion already exists for this app. Surface a
50
+ # useful message instead of letting the caller print the raw
51
+ # "ASC /v1/buildUploads returned 409" string.
52
+ if e.message =~ /\b(409|buildUploads returned 409|duplicate)/i
53
+ raise BuildVersionConflictError,
54
+ "Apple refused the upload: build #{@cf_bundle_version} already exists for this app (CFBundleVersion must be unique). " \
55
+ 'Bump CFBundleVersion in your Xcode project and re-archive, then retry.'
56
+ end
57
+ raise
58
+ end
59
+ data = resp[:data]
60
+ build_upload_id = data['build_upload_id']
61
+ ops = data['upload_operations']
62
+
63
+ File.open(@ipa_path, 'rb') do |f|
64
+ ops.each { |op| put_chunk_with_retry(f, op) }
65
+ end
66
+
67
+ md5 = Digest::MD5.file(@ipa_path).hexdigest
68
+ sha = Digest::SHA256.file(@ipa_path).hexdigest
69
+
70
+ @client.patch(
71
+ "/api/v1/organizations/#{@org_id}/builds/asc_upload/#{build_upload_id}",
72
+ body: { uploaded: true, source_file_checksums: { md5: md5, sha256: sha } }
73
+ )
74
+
75
+ final = poll_until_terminal(build_upload_id)
76
+ { build_upload_id: build_upload_id, final_state: final }
77
+ end
78
+
79
+ private
80
+
81
+ def put_chunk_with_retry(file, operation)
82
+ # Defense-in-depth: Apple's signed URLs are always https. If the
83
+ # server ever returns an http:// URL, refuse to PUT the chunk — that
84
+ # would leak the .ipa bytes (and auth headers) in clear text.
85
+ scheme = URI.parse(operation['url'].to_s).scheme
86
+ raise "refusing non-https upload URL (scheme=#{scheme.inspect})" unless scheme == 'https'
87
+
88
+ file.seek(operation['offset'])
89
+ bytes = file.read(operation['length'])
90
+ attempts = 0
91
+ begin
92
+ conn = Faraday.new { |f| f.adapter Faraday.default_adapter }
93
+ resp = conn.public_send(operation['method'].downcase) do |req|
94
+ req.url operation['url']
95
+ (operation['requestHeaders'] || []).each { |h| req.headers[h['name']] = h['value'] }
96
+ req.body = bytes
97
+ end
98
+ raise "chunk PUT failed #{resp.status}" unless resp.status.between?(200, 299)
99
+ rescue StandardError
100
+ attempts += 1
101
+ retry if attempts <= CHUNK_RETRIES
102
+ raise
103
+ end
104
+ end
105
+
106
+ def poll_until_terminal(build_upload_id)
107
+ deadline = Time.now + @poll_timeout
108
+ loop do
109
+ resp = @client.get("/api/v1/organizations/#{@org_id}/builds/asc_upload/#{build_upload_id}")
110
+ state = resp[:data]['apple_state']
111
+ return state if TERMINAL_APPLE_STATES.include?(state)
112
+ return 'TIMEOUT' if Time.now > deadline
113
+
114
+ sleep @poll_interval
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -1,5 +1,6 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'json'
2
- require 'stringio'
3
4
 
4
5
  module Mysigner
5
6
  module Upload
@@ -7,12 +8,12 @@ module Mysigner
7
8
  class UploadError < Mysigner::Error; end
8
9
  class CredentialsError < UploadError; end
9
10
  class TrackError < UploadError; end
10
-
11
+
11
12
  # Special error for when AAB uploaded but track assignment failed
12
13
  # This carries the version_code so it can be saved to prevent conflicts
13
14
  class PartialUploadError < UploadError
14
15
  attr_reader :version_code
15
-
16
+
16
17
  def initialize(message, version_code:)
17
18
  super(message)
18
19
  @version_code = version_code
@@ -20,13 +21,19 @@ module Mysigner
20
21
  end
21
22
 
22
23
  VALID_TRACKS = %w[internal alpha beta production].freeze
23
- SCOPE = 'https://www.googleapis.com/auth/androidpublisher'.freeze
24
+ SCOPE = 'https://www.googleapis.com/auth/androidpublisher'
24
25
 
25
- def initialize(aab_path:, service_account_json:, package_name:)
26
+ # Phase 0: accepts a short-lived OAuth2 access_token (minted server-side
27
+ # from the customer's service-account JSON). The JSON no longer leaves
28
+ # the server. google-api-ruby-client accepts a bare string for
29
+ # authorization= and sends it as `Authorization: Bearer <token>`.
30
+ def initialize(aab_path:, access_token:, package_name:)
26
31
  @aab_path = File.expand_path(aab_path)
27
- @service_account_json = service_account_json
32
+ @access_token = access_token
28
33
  @package_name = package_name
29
34
 
35
+ raise CredentialsError, 'access_token is required' if @access_token.nil? || @access_token.to_s.empty?
36
+
30
37
  validate_aab!
31
38
  setup_google_client!
32
39
  end
@@ -35,13 +42,22 @@ module Mysigner
35
42
  # @param track [String] Track to assign: internal, alpha, beta, production
36
43
  # @param release_notes [Hash] Localized release notes { 'en-US' => 'What\'s new...' }
37
44
  # @param user_fraction [Float] Rollout percentage (0.0-1.0) for staged rollouts
45
+ # @param status [String] Explicit release status: draft | inProgress | completed.
46
+ # Overrides the user_fraction-derived default. `draft` is useful for
47
+ # "upload, don't release yet" flows that iOS-MANUAL users expect.
48
+ # @param in_app_update_priority [Integer] 0–5 priority hint for in-app update flows
49
+ # @param release_name [String] Optional release name (defaults to AAB versionName)
50
+ # @param country_targeting [Hash] { countries: ['US','CA'], include_rest_of_world: false }
51
+ # @param changes_not_sent_for_review [Boolean] Skip submitting changes to Play review on commit
38
52
  # @return [Hash] Upload result with version_code and track info
39
- def upload!(track: 'internal', release_notes: nil, user_fraction: nil)
40
- @current_track = track # Store for error messages
53
+ def upload!(track: 'internal', release_notes: nil, user_fraction: nil,
54
+ status: nil, in_app_update_priority: nil, release_name: nil,
55
+ country_targeting: nil, changes_not_sent_for_review: nil)
56
+ @current_track = track # Store for error messages
41
57
  say_uploading(track)
42
58
 
43
59
  version_code = nil
44
-
60
+
45
61
  begin
46
62
  # 1. Create an edit
47
63
  edit = create_edit
@@ -54,11 +70,19 @@ module Mysigner
54
70
 
55
71
  # 3. Assign to track with release
56
72
  if track
57
- assign_to_track(edit.id, track, version_code, release_notes: release_notes, user_fraction: user_fraction)
73
+ assign_to_track(
74
+ edit.id, track, version_code,
75
+ release_notes: release_notes,
76
+ user_fraction: user_fraction,
77
+ status: status,
78
+ in_app_update_priority: in_app_update_priority,
79
+ release_name: release_name,
80
+ country_targeting: country_targeting
81
+ )
58
82
  end
59
83
 
60
84
  # 4. Commit the edit
61
- commit_edit(edit.id)
85
+ commit_edit(edit.id, changes_not_sent_for_review: changes_not_sent_for_review)
62
86
 
63
87
  say_success(track, version_code)
64
88
 
@@ -71,20 +95,16 @@ module Mysigner
71
95
  rescue Google::Apis::ClientError => e
72
96
  error_message = parse_google_error(e)
73
97
  # If AAB was uploaded, raise PartialUploadError so CLI can save the version
74
- if version_code
75
- raise PartialUploadError.new("Google Play API error: #{error_message}", version_code: version_code)
76
- else
77
- raise UploadError, "Google Play API error: #{error_message}"
78
- end
98
+ raise PartialUploadError.new("Google Play API error: #{error_message}", version_code: version_code) if version_code
99
+
100
+ raise UploadError, "Google Play API error: #{error_message}"
79
101
  rescue PartialUploadError
80
102
  # Re-raise as-is
81
103
  raise
82
- rescue => e
83
- if version_code
84
- raise PartialUploadError.new("Upload failed: #{e.message}", version_code: version_code)
85
- else
86
- raise UploadError, "Upload failed: #{e.message}"
87
- end
104
+ rescue StandardError => e
105
+ raise PartialUploadError.new("Upload failed: #{e.message}", version_code: version_code) if version_code
106
+
107
+ raise UploadError, "Upload failed: #{e.message}"
88
108
  end
89
109
  end
90
110
 
@@ -110,14 +130,14 @@ module Mysigner
110
130
  rescue Google::Apis::ClientError => e
111
131
  error_message = parse_google_error(e)
112
132
  raise UploadError, "Google Play API error: #{error_message}"
113
- rescue => e
133
+ rescue StandardError => e
114
134
  raise UploadError, "Upload failed: #{e.message}"
115
135
  end
116
136
  end
117
137
 
118
138
  # Assign an existing version code to a track
119
139
  def assign_existing_to_track!(version_code, track:, release_notes: nil, user_fraction: nil)
120
- @current_track = track # Store for error messages
140
+ @current_track = track # Store for error messages
121
141
  begin
122
142
  edit = create_edit
123
143
  assign_to_track(edit.id, track, version_code, release_notes: release_notes, user_fraction: user_fraction)
@@ -138,55 +158,36 @@ module Mysigner
138
158
  private
139
159
 
140
160
  def validate_aab!
141
- unless File.exist?(@aab_path)
142
- raise UploadError, "AAB file not found: #{@aab_path}"
143
- end
161
+ raise UploadError, "AAB file not found: #{@aab_path}" unless File.exist?(@aab_path)
144
162
 
145
- unless @aab_path.end_with?('.aab')
146
- raise UploadError, "Invalid file type: #{@aab_path} (must be .aab)"
147
- end
163
+ raise UploadError, "Invalid file type: #{@aab_path} (must be .aab)" unless @aab_path.end_with?('.aab')
148
164
 
149
165
  file_size = File.size(@aab_path)
150
- if file_size < 10_000
151
- raise UploadError, "AAB file seems too small: #{file_size} bytes (possible corruption)"
152
- end
166
+ return unless file_size < 10_000
167
+
168
+ raise UploadError, "AAB file seems too small: #{file_size} bytes (possible corruption)"
153
169
  end
154
170
 
155
171
  def setup_google_client!
156
- require 'googleauth'
157
172
  require 'google/apis/androidpublisher_v3'
158
173
 
159
- # Parse and validate service account JSON
160
- begin
161
- @credentials_data = JSON.parse(@service_account_json)
162
- rescue JSON::ParserError => e
163
- raise CredentialsError, "Invalid service account JSON: #{e.message}"
164
- end
165
-
166
- # Build authorization
167
- @auth = Google::Auth::ServiceAccountCredentials.make_creds(
168
- json_key_io: StringIO.new(@service_account_json),
169
- scope: SCOPE
170
- )
171
-
172
- # Create service
174
+ # Create service with the bare bearer token. google-api-ruby-client
175
+ # sends a string authorization as `Authorization: Bearer <string>`.
173
176
  @service = Google::Apis::AndroidpublisherV3::AndroidPublisherService.new
174
- @service.authorization = @auth
177
+ @service.authorization = @access_token
175
178
  @service.client_options.open_timeout_sec = 30
176
- @service.client_options.read_timeout_sec = 300 # Large file uploads need time
179
+ @service.client_options.read_timeout_sec = 300 # Large file uploads need time
177
180
  @service.request_options.retries = 3
178
-
179
- rescue LoadError => e
180
- raise CredentialsError, "Google API client not installed. Run: gem install google-api-client"
181
+ rescue LoadError
182
+ raise CredentialsError, 'Google API client not installed. Run: gem install google-api-client'
181
183
  end
182
184
 
183
185
  def create_edit
184
186
  edit = Google::Apis::AndroidpublisherV3::AppEdit.new
185
187
  @service.insert_edit(@package_name, edit)
186
188
  rescue Google::Apis::ClientError => e
187
- if e.message.include?("Package not found") || e.status_code == 404
188
- raise UploadError, first_upload_error_message
189
- end
189
+ raise UploadError, first_upload_error_message if e.message.include?('Package not found') || e.status_code == 404
190
+
190
191
  raise
191
192
  end
192
193
 
@@ -209,39 +210,50 @@ module Mysigner
209
210
 
210
211
  def upload_bundle(edit_id)
211
212
  puts "šŸ“¦ Uploading AAB (#{format_bytes(File.size(@aab_path))})..."
212
- puts ""
213
+ puts ''
213
214
 
214
215
  begin
215
- result = @service.upload_edit_bundle(
216
+ @service.upload_edit_bundle(
216
217
  @package_name,
217
218
  edit_id,
218
219
  upload_source: @aab_path,
219
220
  content_type: 'application/octet-stream'
220
221
  )
221
- result
222
222
  rescue Google::Apis::ClientError => e
223
223
  error_msg = parse_google_error(e)
224
224
  raise UploadError, "Bundle upload failed: #{error_msg}"
225
- rescue => e
225
+ rescue StandardError => e
226
226
  raise UploadError, "Bundle upload failed: #{e.message}"
227
227
  end
228
228
  end
229
229
 
230
- def assign_to_track(edit_id, track, version_code, release_notes: nil, user_fraction: nil)
231
- unless VALID_TRACKS.include?(track)
232
- raise TrackError, "Invalid track '#{track}'. Valid tracks: #{VALID_TRACKS.join(', ')}"
233
- end
230
+ def assign_to_track(edit_id, track, version_code, release_notes: nil, user_fraction: nil,
231
+ status: nil, in_app_update_priority: nil, release_name: nil,
232
+ country_targeting: nil)
233
+ raise TrackError, "Invalid track '#{track}'. Valid tracks: #{VALID_TRACKS.join(', ')}" unless VALID_TRACKS.include?(track)
234
234
 
235
235
  puts "šŸš‚ Assigning to #{track} track..."
236
236
 
237
- # Build release
237
+ # Status precedence: explicit `status:` arg > user_fraction-derived >
238
+ # completed. Google Play rejects `userFraction` outside (0,1) and also
239
+ # rejects it when status != inProgress, so we clear it when the
240
+ # caller-provided status is non-rollout.
241
+ effective_status = if status && %w[draft inProgress halted completed].include?(status)
242
+ status
243
+ elsif user_fraction
244
+ 'inProgress'
245
+ else
246
+ 'completed'
247
+ end
248
+
238
249
  release = Google::Apis::AndroidpublisherV3::TrackRelease.new(
239
250
  version_codes: [version_code.to_s],
240
- status: user_fraction ? 'inProgress' : 'completed'
251
+ status: effective_status
241
252
  )
242
253
 
243
- # Add release notes if provided
244
- if release_notes && release_notes.any?
254
+ release.name = release_name if release_name
255
+
256
+ if release_notes&.any?
245
257
  release.release_notes = release_notes.map do |lang, text|
246
258
  Google::Apis::AndroidpublisherV3::LocalizedText.new(
247
259
  language: lang,
@@ -250,12 +262,16 @@ module Mysigner
250
262
  end
251
263
  end
252
264
 
253
- # Add user fraction for staged rollouts
254
- if user_fraction
255
- release.user_fraction = user_fraction
265
+ release.user_fraction = user_fraction if user_fraction && effective_status == 'inProgress'
266
+ release.in_app_update_priority = in_app_update_priority if in_app_update_priority
267
+
268
+ if country_targeting.is_a?(Hash) && country_targeting[:countries].is_a?(Array) && country_targeting[:countries].any?
269
+ release.country_targeting = Google::Apis::AndroidpublisherV3::CountryTargeting.new(
270
+ countries: country_targeting[:countries],
271
+ include_rest_of_world: country_targeting.fetch(:include_rest_of_world, false)
272
+ )
256
273
  end
257
274
 
258
- # Build track update
259
275
  track_obj = Google::Apis::AndroidpublisherV3::Track.new(
260
276
  track: track,
261
277
  releases: [release]
@@ -264,104 +280,108 @@ module Mysigner
264
280
  @service.update_edit_track(@package_name, edit_id, track, track_obj)
265
281
  end
266
282
 
267
- def commit_edit(edit_id)
268
- puts "šŸ’¾ Committing changes..."
269
- begin
270
- # Try with changesNotSentForReview first (for apps without managed review)
271
- @service.commit_edit(@package_name, edit_id, changes_not_sent_for_review: true)
272
- rescue Google::Apis::ClientError => e
273
- error_text = e.message.to_s
274
- # Also check body if present
275
- if e.respond_to?(:body) && e.body
276
- error_text += " #{e.body}"
277
- end
278
-
279
- if error_text.include?('changesNotSentForReview')
280
- # App has managed review enabled, commit without the flag
283
+ # Commit the edit. When `changes_not_sent_for_review` is nil (the
284
+ # historical default) we opt-in (true) and fall back to false if Google
285
+ # rejects — some apps have Play-managed review that forbids the flag.
286
+ # When the caller passes an explicit boolean (from cli_defaults), we
287
+ # pass it through without the fallback retry.
288
+ def commit_edit(edit_id, changes_not_sent_for_review: nil)
289
+ puts 'šŸ’¾ Committing changes...'
290
+
291
+ if changes_not_sent_for_review.nil?
292
+ begin
293
+ @service.commit_edit(@package_name, edit_id, changes_not_sent_for_review: true)
294
+ rescue Google::Apis::ClientError => e
295
+ error_text = e.message.to_s
296
+ error_text += " #{e.body}" if e.respond_to?(:body) && e.body
297
+ raise unless error_text.include?('changesNotSentForReview')
298
+
281
299
  @service.commit_edit(@package_name, edit_id)
282
- else
283
- raise
284
300
  end
301
+ else
302
+ @service.commit_edit(@package_name, edit_id, changes_not_sent_for_review: !!changes_not_sent_for_review)
285
303
  end
286
304
  end
287
305
 
288
306
  def parse_google_error(error)
289
- begin
290
- body = nil
291
- if error.respond_to?(:body) && error.body
292
- body = JSON.parse(error.body) rescue nil
293
- end
294
-
295
- message = body&.dig('error', 'message') || error.message
296
- details = body&.dig('error', 'errors')&.map { |e| e['message'] }&.join('; ')
297
- full_message = details ? "#{message} (#{details})" : message
298
-
299
- # Provide helpful context for common errors
300
- case full_message.to_s.downcase
301
- when /package.*not found/i, /app not found/i
302
- "#{full_message}\n\nšŸ’” Make sure the package name '#{@package_name}' matches your app in Google Play Console."
303
- when /not authorized/i, /permission denied/i, /forbidden/i
304
- "#{full_message}\n\nšŸ’” Check that your service account has Editor or Admin access to the app in Google Play Console."
305
- when /version.*code.*already/i, /already.*used/i
306
- "#{full_message}\n\nšŸ’” Version code already exists. Increment versionCode in android/app/build.gradle and rebuild."
307
- when /precondition.*check.*failed/i, /precondition.*failed/i
308
- track_name = @current_track || "this track"
309
- "#{full_message}\n\n" \
310
- "šŸ’” Google Play Console requires setup before publishing to #{track_name}:\n\n" \
311
- " For PRODUCTION track:\n" \
312
- " • Complete store listing (description, screenshots, etc.)\n" \
313
- " • Set content rating\n" \
314
- " • Configure pricing & distribution\n\n" \
315
- " For BETA/ALPHA tracks:\n" \
316
- " • Create a closed/open testing track in Play Console\n" \
317
- " • Add at least one tester email\n\n" \
318
- " For INTERNAL track:\n" \
319
- " • Add internal testers in Play Console\n\n" \
320
- " āœ… Your AAB was uploaded successfully!\n" \
321
- " → Go to Play Console to complete track setup, then use:\n" \
322
- " mysigner submit #{track_name} --platform android --version-code VERSION"
323
- when /invalid request/i
324
- "#{full_message}\n\nšŸ’” Common causes:\n" \
325
- " • Version code not found on Google Play (must upload first)\n" \
326
- " • App not created in Google Play Console\n" \
327
- " • Service account missing permissions"
328
- when /signing/i, /signature/i
329
- "#{full_message}\n\nšŸ’” The AAB may not be signed with the correct key. Check your keystore matches what's registered in Play Console."
330
- else
331
- full_message
307
+ body = nil
308
+ if error.respond_to?(:body) && error.body
309
+ body = begin
310
+ JSON.parse(error.body)
311
+ rescue StandardError
312
+ nil
332
313
  end
333
- rescue => parse_error
334
- "#{error.message} (parsing error: #{parse_error.message})"
335
314
  end
315
+
316
+ message = body&.dig('error', 'message') || error.message
317
+ details = body&.dig('error', 'errors')&.map { |e| e['message'] }&.join('; ')
318
+ full_message = details ? "#{message} (#{details})" : message
319
+
320
+ # Provide helpful context for common errors
321
+ case full_message.to_s.downcase
322
+ when /package.*not found/i, /app not found/i
323
+ "#{full_message}\n\nšŸ’” Make sure the package name '#{@package_name}' matches your app in Google Play Console."
324
+ when /not authorized/i, /permission denied/i, /forbidden/i
325
+ "#{full_message}\n\nšŸ’” Check that your service account has Editor or Admin access to the app in Google Play Console."
326
+ when /version.*code.*already/i, /already.*used/i
327
+ "#{full_message}\n\nšŸ’” Version code already exists. Increment versionCode in android/app/build.gradle and rebuild."
328
+ when /precondition.*check.*failed/i, /precondition.*failed/i
329
+ track_name = @current_track || 'this track'
330
+ "#{full_message}\n\n" \
331
+ "šŸ’” Google Play Console requires setup before publishing to #{track_name}:\n\n " \
332
+ "For PRODUCTION track:\n " \
333
+ "• Complete store listing (description, screenshots, etc.)\n " \
334
+ "• Set content rating\n " \
335
+ "• Configure pricing & distribution\n\n " \
336
+ "For BETA/ALPHA tracks:\n " \
337
+ "• Create a closed/open testing track in Play Console\n " \
338
+ "• Add at least one tester email\n\n " \
339
+ "For INTERNAL track:\n " \
340
+ "• Add internal testers in Play Console\n\n " \
341
+ "āœ… Your AAB was uploaded successfully!\n " \
342
+ "→ Go to Play Console to complete track setup, then use:\n " \
343
+ "mysigner submit #{track_name} --platform android --version-code VERSION"
344
+ when /invalid request/i
345
+ "#{full_message}\n\nšŸ’” Common causes:\n " \
346
+ "• Version code not found on Google Play (must upload first)\n " \
347
+ "• App not created in Google Play Console\n " \
348
+ '• Service account missing permissions'
349
+ when /signing/i, /signature/i
350
+ "#{full_message}\n\nšŸ’” The AAB may not be signed with the correct key. Check your keystore matches what's registered in Play Console."
351
+ else
352
+ full_message
353
+ end
354
+ rescue StandardError => e
355
+ "#{error.message} (parsing error: #{e.message})"
336
356
  end
337
357
 
338
358
  def say_uploading(track)
339
- puts "ā˜ļø Uploading to Google Play#{track ? " (#{track} track)" : ''}..."
340
- puts ""
359
+ puts "ā˜ļø Uploading to Google Play#{" (#{track} track)" if track}..."
360
+ puts ''
341
361
  puts "AAB: #{File.basename(@aab_path)}"
342
362
  puts "Size: #{format_bytes(File.size(@aab_path))}"
343
363
  puts "Package: #{@package_name}"
344
364
  puts "Track: #{track || 'none (upload only)'}"
345
- puts ""
365
+ puts ''
346
366
  end
347
367
 
348
368
  def say_upload_success(version_code)
349
369
  puts "āœ“ Bundle uploaded successfully (version code: #{version_code})"
350
- puts ""
370
+ puts ''
351
371
  end
352
372
 
353
373
  def say_success(track, version_code)
354
- puts ""
355
- puts "=" * 80
356
- puts "āœ“ Upload complete!"
357
- puts "=" * 80
358
- puts ""
374
+ puts ''
375
+ puts '=' * 80
376
+ puts 'āœ“ Upload complete!'
377
+ puts '=' * 80
378
+ puts ''
359
379
  puts "šŸŽ‰ Your app is now on Google Play (#{track} track)"
360
- puts ""
380
+ puts ''
361
381
  puts "Version Code: #{version_code}"
362
382
  puts "Package: #{@package_name}"
363
383
  puts "Track: #{track}"
364
- puts ""
384
+ puts ''
365
385
  end
366
386
 
367
387
  def format_bytes(bytes)