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
@@ -1,18 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'json'
2
4
  require 'yaml'
3
5
  require 'time'
4
6
  require_relative '../upload/play_store_uploader'
5
7
  require_relative '../upload/app_store_automation'
6
8
  require_relative '../upload/app_store_submission'
9
+ require_relative '../upload/asc_rest_uploader'
7
10
 
8
11
  module Mysigner
9
12
  class CLI < Thor
10
13
  module BuildCommands
11
- MetadataFileError = Class.new(StandardError)
14
+ class MetadataFileError < StandardError
15
+ end
12
16
 
13
17
  def self.included(base)
14
18
  base.class_eval do
15
- desc "ship TARGET", "🚀 Build + upload (iOS: testflight/appstore, Android: internal/alpha/beta/production)"
19
+ desc 'ship TARGET', '🚀 Build + upload (iOS: testflight/appstore, Android: internal/alpha/beta/production)'
16
20
  long_desc <<~DESC
17
21
  Build your project, sign it, and upload in one go.
18
22
 
@@ -42,7 +46,7 @@ module Mysigner
42
46
  WORKFLOW
43
47
  For iOS TestFlight:
44
48
  mysigner ship testflight # Build → Upload → Done!
45
-
49
+ #{' '}
46
50
  For Android Internal Testing:
47
51
  mysigner ship internal --platform android # Build → Upload → Done!
48
52
 
@@ -60,20 +64,22 @@ module Mysigner
60
64
  method_option :bundle_id, aliases: '-b', desc: 'Bundle ID (overrides project setting)'
61
65
  method_option :platform, type: :string, desc: 'Platform: ios or android (auto-detect if not specified)'
62
66
  method_option :package_name, type: :string, desc: 'Android package name (overrides project setting)'
63
- method_option :release_notes, type: :string, desc: 'Release notes for Android Play Store'
67
+ method_option :release_notes, type: :string, desc: 'Release notes for Play Store (Android) / App Store (iOS whatsNew)'
68
+ method_option :metadata_file, type: :string, desc: 'Path to metadata JSON file (iOS App Store submissions)'
64
69
  method_option :version, type: :string, desc: 'Set version name for Android (e.g., 1.2.0)'
65
- method_option :release_type, type: :string, enum: %w[AFTER_APPROVAL MANUAL SCHEDULED],
66
- desc: 'Release type for App Store: AFTER_APPROVAL, MANUAL, or SCHEDULED'
67
- method_option :scheduled_date, type: :string, banner: 'ISO8601',
68
- desc: 'Scheduled release date (ISO 8601, e.g., 2026-02-01T10:00:00Z)'
70
+ method_option :release_type, type: :string, enum: %w[AFTER_APPROVAL MANUAL SCHEDULED],
71
+ desc: 'Release type for App Store: AFTER_APPROVAL, MANUAL, or SCHEDULED'
72
+ method_option :scheduled_date, type: :string, banner: 'ISO8601',
73
+ desc: 'Scheduled release date (ISO 8601, e.g., 2026-02-01T10:00:00Z)'
74
+ method_option :auto_submit, type: :boolean,
75
+ desc: 'Submit for App Store review after upload. Defaults to dashboard CLI Defaults, else true for ship appstore. Use --no-auto-submit to skip.'
69
76
  def ship(target)
70
- ios_targets = ['testflight', 'appstore']
71
- android_targets = ['internal', 'alpha', 'beta', 'production']
72
- all_targets = ios_targets + android_targets
77
+ ios_targets = %w[testflight appstore]
78
+ android_targets = %w[internal alpha beta production]
73
79
 
74
80
  # Determine platform from option or target
75
81
  platform = options[:platform]&.to_sym
76
-
82
+
77
83
  if platform.nil?
78
84
  # Auto-detect from target
79
85
  if ios_targets.include?(target)
@@ -106,64 +112,63 @@ module Mysigner
106
112
  end
107
113
 
108
114
  # iOS flow continues below...
109
-
115
+
110
116
  is_appstore = (target == 'appstore')
111
117
 
112
118
  config = load_config
113
119
  client = create_client(config)
114
-
120
+
115
121
  overall_start = Time.now
116
122
  timings = {}
117
123
  archive_path = nil
118
124
  ipa_path = nil
119
- project_name = nil
125
+ nil
120
126
  bundle_id = nil
121
127
 
122
- target_label = is_appstore ? "App Store" : "TestFlight"
128
+ target_label = is_appstore ? 'App Store' : 'TestFlight'
123
129
  say "🚀 My Signer - Ship to #{target_label}", :cyan
124
- say "=" * 80, :cyan
125
- say ""
126
- say "This will:", :bold
127
- say " 1️⃣ Detect and build your project"
128
- say " 2️⃣ Export IPA for App Store"
130
+ say '=' * 80, :cyan
131
+ say ''
132
+ say 'This will:', :bold
133
+ say ' 1️⃣ Detect and build your project'
134
+ say ' 2️⃣ Export IPA for App Store'
129
135
  say " 3️⃣ Upload to #{target_label}"
130
136
  if is_appstore
131
- say " 4️⃣ Wait for Apple to process build"
132
- say " 5️⃣ Submit for App Store review"
137
+ say ' 4️⃣ Wait for Apple to process build'
138
+ say ' 5️⃣ Submit for App Store review'
133
139
  end
134
- say ""
140
+ say ''
135
141
  say "⏱️ Estimated time: #{is_appstore ? '15-30 minutes' : '3-7 minutes'}", :yellow
136
- say ""
142
+ say ''
137
143
 
138
144
  begin
139
-
140
145
  # STEP 1: BUILD
141
- say "=" * 80, :cyan
142
- say "[1/3] Building Archive", :cyan
143
- say "=" * 80, :cyan
144
- say ""
146
+ say '=' * 80, :cyan
147
+ say '[1/3] Building Archive', :cyan
148
+ say '=' * 80, :cyan
149
+ say ''
145
150
 
146
151
  build_start = Time.now
147
-
152
+
148
153
  # Detect project
149
154
  project_info = Build::Detector.detect
150
155
  project_name = File.basename(project_info[:path], '.*')
151
-
156
+
152
157
  framework_label = case project_info[:framework]
153
- when :capacitor then "Capacitor/Ionic"
154
- when :react_native then "React Native"
155
- when :flutter then "Flutter"
156
- else "Native iOS"
157
- end
158
-
158
+ when :capacitor then 'Capacitor/Ionic'
159
+ when :react_native then 'React Native'
160
+ when :flutter then 'Flutter'
161
+ else 'Native iOS'
162
+ end
163
+
159
164
  say "✓ Found: #{File.basename(project_info[:path])} (#{framework_label})", :green
160
- say ""
165
+ say ''
161
166
 
162
167
  # Parse and build
163
168
  parser = Build::Parser.new(project_info)
164
169
  target_name = options[:target] || parser.main_target.name
165
170
  bundle_id = options[:bundle_id] || parser.bundle_id(target_name, options[:configuration])
166
-
171
+
167
172
  # Validate bundle ID format if overridden
168
173
  if options[:bundle_id]
169
174
  if bundle_id =~ /\$\(|\$\{/
@@ -171,69 +176,70 @@ module Mysigner
171
176
  exit 1
172
177
  elsif bundle_id !~ /^[a-zA-Z0-9.-]+$/
173
178
  error "Invalid bundle ID format: #{bundle_id}"
174
- say "Bundle IDs must contain only letters, numbers, hyphens, and periods", :yellow
179
+ say 'Bundle IDs must contain only letters, numbers, hyphens, and periods', :yellow
175
180
  exit 1
176
181
  end
177
182
  end
178
-
183
+
179
184
  say "🎯 Target: #{target_name}", :cyan
180
- say "📦 Bundle ID: #{bundle_id}#{options[:bundle_id] ? ' (overridden)' : ''}", :cyan
181
- say "⏱️ Estimated: 2-5 minutes", :yellow
182
- say ""
183
-
185
+ say "📦 Bundle ID: #{bundle_id}#{' (overridden)' if options[:bundle_id]}", :cyan
186
+ say '⏱️ Estimated: 2-5 minutes', :yellow
187
+ say ''
188
+
184
189
  # Auto-fetch team ID from API if not provided and project missing it
185
190
  team_id_to_use = options[:team]
186
191
  project_team_id = parser.team_id(target_name, options[:configuration])
187
-
192
+
188
193
  if !team_id_to_use && (project_team_id.nil? || project_team_id.empty?)
189
- say "🔍 No team set in project, fetching from My Signer...", :yellow
190
-
194
+ say '🔍 No team set in project, fetching from My Signer...', :yellow
195
+
191
196
  begin
192
197
  org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
193
- api_team_id = org_response.dig(:data, 'app_store_connect_team_id') || org_response['app_store_connect_team_id']
194
-
198
+ api_team_id = org_response.dig(:data,
199
+ 'app_store_connect_team_id') || org_response['app_store_connect_team_id']
200
+
195
201
  if api_team_id && !api_team_id.empty?
196
202
  team_id_to_use = api_team_id
197
203
  say "✓ Using team from My Signer: #{api_team_id}", :green
198
204
  else
199
- say "⚠️ No team ID configured in My Signer", :yellow
205
+ say '⚠️ No team ID configured in My Signer', :yellow
200
206
  end
201
- rescue => e
207
+ rescue StandardError => e
202
208
  say "⚠️ Failed to fetch team from API: #{e.message}", :yellow
203
209
  end
204
210
  end
205
- say ""
206
-
211
+ say ''
212
+
207
213
  # Pre-build validation
208
- say "🔍 Validating signing setup...", :cyan
214
+ say '🔍 Validating signing setup...', :cyan
209
215
  validator = Signing::Validator.new(parser, target_name, options[:configuration], team_id: team_id_to_use)
210
216
  validator.validate!
211
-
217
+
212
218
  executor = Build::Executor.new(project_info, parser)
213
219
  archive_path = executor.build!(
214
- target_name,
215
- options[:configuration],
220
+ target_name,
221
+ options[:configuration],
216
222
  scheme: options[:scheme],
217
223
  signing_style: parser.code_sign_style(target_name, options[:configuration]),
218
224
  team_id: team_id_to_use
219
225
  )
220
226
 
221
227
  timings[:build] = Time.now - build_start
222
-
223
- say ""
228
+
229
+ say ''
224
230
  say "✓ Build complete in #{format_duration(timings[:build])}", :green
225
- say ""
231
+ say ''
226
232
 
227
233
  # STEP 2: EXPORT
228
- say "=" * 80, :cyan
229
- say "[2/3] Exporting IPA", :cyan
230
- say "=" * 80, :cyan
231
- say ""
232
- say "⏱️ Estimated: 30-90 seconds", :yellow
233
- say ""
234
+ say '=' * 80, :cyan
235
+ say '[2/3] Exporting IPA', :cyan
236
+ say '=' * 80, :cyan
237
+ say ''
238
+ say '⏱️ Estimated: 30-90 seconds', :yellow
239
+ say ''
234
240
 
235
241
  export_start = Time.now
236
-
242
+
237
243
  exporter = Export::Exporter.new(archive_path)
238
244
  ipa_path = exporter.export!(
239
245
  method: :appstore,
@@ -242,147 +248,200 @@ module Mysigner
242
248
  )
243
249
 
244
250
  timings[:export] = Time.now - export_start
245
-
246
- say ""
251
+
252
+ say ''
247
253
  say "✓ Export complete in #{format_duration(timings[:export])}", :green
248
254
  say "📦 IPA: #{ipa_path}", :cyan
249
- say ""
255
+ say ''
250
256
 
251
257
  # STEP 2.5: Get current latest build (BEFORE upload) - App Store only
252
258
  latest_build_before_upload = nil
253
259
  if is_appstore
254
- say "=" * 80, :cyan
255
- say "Getting Current Latest Build", :cyan
256
- say "=" * 80, :cyan
257
- say ""
258
-
259
- say "🔄 Syncing from App Store Connect...", :yellow
260
+ say '=' * 80, :cyan
261
+ say 'Getting Current Latest Build', :cyan
262
+ say '=' * 80, :cyan
263
+ say ''
264
+
265
+ say '🔄 Syncing from App Store Connect...', :yellow
260
266
  begin
261
267
  client.post("/api/v1/organizations/#{config.current_organization_id}/sync", body: { force: true })
262
268
  sleep 15
263
- say "✓ Sync complete", :green
264
- rescue => e
269
+ say '✓ Sync complete', :green
270
+ rescue StandardError => e
265
271
  say "⚠️ Sync failed: #{e.message}", :yellow
266
272
  end
267
- say ""
268
-
273
+ say ''
274
+
269
275
  begin
270
- app_response = client.get("/api/v1/organizations/#{config.current_organization_id}/apple_apps", params: { bundle_id: bundle_id })
276
+ app_response = client.get("/api/v1/organizations/#{config.current_organization_id}/apple_apps",
277
+ params: { bundle_id: bundle_id })
271
278
  app = Array(app_response.dig(:data, 'data', 'apps')).first
272
279
 
273
280
  if app
274
- builds_response = client.get("/api/v1/organizations/#{config.current_organization_id}/builds", params: { app_id: app['id'] })
281
+ builds_response = client.get("/api/v1/organizations/#{config.current_organization_id}/builds",
282
+ params: { app_id: app['id'] })
275
283
  latest = Array(builds_response.dig(:data, 'data', 'builds')).first
276
284
  if latest
277
285
  latest_build_before_upload = latest['build_number'].to_i
278
286
  say "✓ Current latest build: ##{latest_build_before_upload}", :green
279
287
  else
280
- say "✓ No builds yet", :green
288
+ say '✓ No builds yet', :green
281
289
  latest_build_before_upload = 0
282
290
  end
283
291
  end
284
- rescue => e
292
+ rescue StandardError => e
285
293
  say "⚠️ Could not fetch builds: #{e.message}", :yellow
286
294
  latest_build_before_upload = 0
287
295
  end
288
- say ""
296
+ say ''
289
297
  end
290
-
298
+
291
299
  # STEP 3: UPLOAD
292
- say "=" * 80, :cyan
300
+ say '=' * 80, :cyan
293
301
  say "[3/#{is_appstore ? '5' : '3'}] Uploading to #{target_label}", :cyan
294
- say "=" * 80, :cyan
295
- say ""
296
- say "⏱️ Estimated: 1-3 minutes", :yellow
297
- say ""
298
-
302
+ say '=' * 80, :cyan
303
+ say ''
304
+ say '⏱️ Estimated: 1-3 minutes', :yellow
305
+ say ''
306
+
299
307
  upload_start = Time.now
300
308
 
301
- # Fetch App Store Connect credentials
302
- say "🔐 Fetching App Store Connect credentials...", :yellow
303
-
304
- org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
305
- org_data = org_response[:data]
306
-
307
- unless org_data['app_store_connect_configured']
308
- say ""
309
- say "✗ App Store Connect credentials not configured", :red
310
- say ""
311
- say "Quick fix:", :cyan
312
- say " mysigner doctor # Auto-configure now", :green
313
- say ""
314
- say "Or manually:", :cyan
315
- say " 1. Run: mysigner onboard"
316
- say " 2. Follow Step 5 to add credentials"
317
- say ""
318
- exit 1
319
- end
320
-
321
- api_key = org_data['app_store_connect_key_id']
322
- api_issuer = org_data['app_store_connect_issuer_id']
323
- private_key = org_data['app_store_connect_private_key']
324
-
325
- unless api_key && api_issuer && private_key
326
- say "✗ Error: Invalid credentials received from API", :red
327
- exit 1
309
+ if ENV['MYSIGNER_USE_LEGACY_ASC'] == '1'
310
+ # LEGACY PATH altool with server-fetched .p8. Will be removed in next release.
311
+ say '⚠️ MYSIGNER_USE_LEGACY_ASC=1 — using legacy altool upload path (deprecated).', :yellow
312
+
313
+ # Fetch App Store Connect credentials
314
+ say '🔐 Fetching App Store Connect credentials...', :yellow
315
+
316
+ org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
317
+ org_data = org_response[:data]
318
+
319
+ unless org_data['app_store_connect_configured']
320
+ say ''
321
+ say '✗ App Store Connect credentials not configured', :red
322
+ say ''
323
+ say 'Quick fix:', :cyan
324
+ say ' mysigner doctor # Auto-configure now', :green
325
+ say ''
326
+ say 'Or manually:', :cyan
327
+ say ' 1. Run: mysigner onboard'
328
+ say ' 2. Follow Step 5 to add credentials'
329
+ say ''
330
+ exit 1
331
+ end
332
+
333
+ api_key = org_data['app_store_connect_key_id']
334
+ api_issuer = org_data['app_store_connect_issuer_id']
335
+ private_key = org_data['app_store_connect_private_key']
336
+
337
+ unless api_key && api_issuer && private_key
338
+ say '✗ Error: Invalid credentials received from API', :red
339
+ exit 1
340
+ end
341
+
342
+ say '✓ Credentials loaded', :green
343
+ say ''
344
+
345
+ # Upload (Uploader writes .p8 to Dir.mktmpdir + cleans up in ensure)
346
+ uploader = Upload::Uploader.new(
347
+ ipa_path,
348
+ api_key: api_key,
349
+ api_issuer: api_issuer,
350
+ private_key: private_key
351
+ )
352
+
353
+ uploader.upload!(wait_for_processing: options[:wait])
354
+ else
355
+ # DEFAULT PATH — ASC REST Build Upload API. No .p8 ever leaves the server.
356
+ require_relative '../upload/asc_rest_uploader'
357
+
358
+ # Resolve apple_app_id from bundle_id (may already have been fetched for is_appstore)
359
+ if !defined?(app) || app.nil?
360
+ app_response = client.get("/api/v1/organizations/#{config.current_organization_id}/apple_apps",
361
+ params: { bundle_id: bundle_id })
362
+ app = Array(app_response.dig(:data, 'data', 'apps')).first
363
+ end
364
+
365
+ unless app && app['id']
366
+ say "✗ App not found in MySigner for bundle_id: #{bundle_id}", :red
367
+ say 'Run: mysigner sync ios', :yellow
368
+ exit 1
369
+ end
370
+
371
+ # Read version info from the built IPA
372
+ ipa_info = Upload::Uploader.extract_ipa_info(ipa_path)
373
+ cf_version = ipa_info[:cf_bundle_version] || '1'
374
+ cf_short = ipa_info[:cf_bundle_short_version_string] || '1.0'
375
+
376
+ say "📤 Uploading via App Store Connect REST API (version #{cf_short} build #{cf_version})...", :cyan
377
+
378
+ rest = Mysigner::Upload::AscRestUploader.new(
379
+ client: client,
380
+ organization_id: config.current_organization_id,
381
+ ipa_path: ipa_path,
382
+ apple_app_id: app['id'],
383
+ cf_bundle_version: cf_version,
384
+ cf_bundle_short_version_string: cf_short,
385
+ platform: 'IOS'
386
+ )
387
+
388
+ result = rest.call
389
+ case result[:final_state]
390
+ when 'COMPLETE'
391
+ say '✓ Upload complete — Apple accepted the build', :green
392
+ when 'FAILED', 'INVALIDATED'
393
+ say "✗ Apple rejected the upload: #{result[:final_state]}", :red
394
+ exit 1
395
+ when 'TIMEOUT'
396
+ say '⚠ Apple is still processing — check App Store Connect.', :yellow
397
+ end
328
398
  end
329
-
330
- say "✓ Credentials loaded", :green
331
- say ""
332
-
333
- # Upload
334
- uploader = Upload::Uploader.new(
335
- ipa_path,
336
- api_key: api_key,
337
- api_issuer: api_issuer,
338
- private_key: private_key
339
- )
340
-
341
- uploader.upload!(wait_for_processing: options[:wait])
342
-
399
+
343
400
  timings[:upload] = Time.now - upload_start
344
-
401
+
345
402
  # STEP 4: Submit for App Store Review (appstore only)
346
403
  if is_appstore
347
- say ""
348
- say "=" * 80, :cyan
349
- say "[4/5] Waiting for Apple to Process Build", :cyan
350
- say "=" * 80, :cyan
351
- say ""
352
-
404
+ say ''
405
+ say '=' * 80, :cyan
406
+ say '[4/5] Waiting for Apple to Process Build', :cyan
407
+ say '=' * 80, :cyan
408
+ say ''
409
+
353
410
  submission_start = Time.now
354
-
411
+
355
412
  # Poll sync every 3 minutes until we find a newer build
356
413
  say "⏳ Waiting for build ##{latest_build_before_upload + 1} to sync (polls every 3min)...", :yellow
357
- timeout = 1800 # 30 minutes
358
- poll_interval = 180 # 3 minutes
414
+ timeout = 1800 # 30 minutes
415
+ poll_interval = 180 # 3 minutes
359
416
  start_time = Time.now
360
417
  new_build = nil
361
418
  poll_count = 0
362
-
419
+
363
420
  loop do
364
421
  poll_count += 1
365
422
  elapsed = Time.now - start_time
366
-
423
+
367
424
  # Run sync
368
425
  begin
369
426
  client.post("/api/v1/organizations/#{config.current_organization_id}/sync", body: { force: true })
370
427
  sleep 15
371
- rescue => e
428
+ rescue StandardError => e
372
429
  # Ignore
373
430
  end
374
-
431
+
375
432
  # Check for new build
376
433
  begin
377
- app_response = client.get("/api/v1/organizations/#{config.current_organization_id}/apple_apps", params: { bundle_id: bundle_id })
434
+ app_response = client.get("/api/v1/organizations/#{config.current_organization_id}/apple_apps",
435
+ params: { bundle_id: bundle_id })
378
436
  app = Array(app_response.dig(:data, 'data', 'apps')).first
379
437
 
380
438
  if app
381
- builds_response = client.get("/api/v1/organizations/#{config.current_organization_id}/builds", params: { app_id: app['id'] })
439
+ builds_response = client.get("/api/v1/organizations/#{config.current_organization_id}/builds",
440
+ params: { app_id: app['id'] })
382
441
  latest = Array(builds_response.dig(:data, 'data', 'builds')).first
383
-
442
+
384
443
  current_build_num = latest ? latest['build_number'].to_i : 0
385
-
444
+
386
445
  if current_build_num > latest_build_before_upload
387
446
  new_build = latest
388
447
  say "✅ Build ##{new_build['build_number']} synced! (#{new_build['processing_state']})", :green
@@ -393,27 +452,27 @@ module Mysigner
393
452
  $stdout.flush
394
453
  end
395
454
  end
396
- rescue => e
455
+ rescue StandardError => e
397
456
  say " ⚠️ Could not check builds: #{e.message}", :yellow
398
457
  end
399
-
458
+
400
459
  # Check timeout
401
460
  if elapsed >= timeout
402
- say ""
461
+ say ''
403
462
  say "✗ Timeout after #{(elapsed / 60).to_i} minutes", :red
404
463
  say " Latest build is still ##{latest_build_before_upload}", :yellow
405
464
  exit 1
406
465
  end
407
-
466
+
408
467
  # Wait before next poll
409
468
  sleep poll_interval unless new_build
410
469
  end
411
- say ""
412
-
470
+ say ''
471
+
413
472
  # Step 3: Now wait for the new build to be processed
414
473
  require_relative '../upload/app_store_submission'
415
474
  require_relative '../upload/app_store_automation'
416
-
475
+
417
476
  automation = Upload::AppStoreAutomation.new(
418
477
  client: client,
419
478
  organization_id: config.current_organization_id,
@@ -421,15 +480,27 @@ module Mysigner
421
480
  wait: true,
422
481
  timeout: 1800,
423
482
  poll_interval: 15,
424
- no_submit: false
483
+ no_submit: false,
484
+ # ship appstore defaults to submitting for review, but
485
+ # dashboard `cli_defaults.auto_submit = false` now wins
486
+ # (the old hard-override that clobbered it was removed).
487
+ default_submit: true
425
488
  }
426
489
  )
427
490
 
428
491
  # Submit the new build (use its specific build number)
429
- # Build metadata overrides from CLI options
430
- ship_overrides = { 'auto_submit' => true }
431
- ship_override_keys = ['auto_submit']
432
-
492
+ # Build metadata overrides from CLI options — start from any
493
+ # --metadata-file + --release-notes, then layer in ship-specific
494
+ # release_type/scheduled_date. auto_submit is NOT forced here;
495
+ # precedence is: --auto-submit flag > cli_defaults > command default.
496
+ ship_overrides, override_sources = build_metadata_overrides(options)
497
+ ship_override_keys = []
498
+
499
+ if options.key?(:auto_submit)
500
+ ship_overrides['auto_submit'] = options[:auto_submit]
501
+ ship_override_keys << 'auto_submit'
502
+ end
503
+
433
504
  if options[:release_type]
434
505
  # Validate release_type
435
506
  valid_types = %w[AFTER_APPROVAL MANUAL SCHEDULED]
@@ -441,20 +512,20 @@ module Mysigner
441
512
  end
442
513
  ship_overrides['release_type'] = rt
443
514
  ship_override_keys << 'release_type'
444
-
515
+
445
516
  # Validate scheduled_date is provided when SCHEDULED
446
517
  if rt == 'SCHEDULED' && !options[:scheduled_date]
447
- error "Scheduled release date is required when --release-type=SCHEDULED"
448
- say "Use: --scheduled-date 2026-02-01T10:00:00Z", :yellow
518
+ error 'Scheduled release date is required when --release-type=SCHEDULED'
519
+ say 'Use: --scheduled-date 2026-02-01T10:00:00Z', :yellow
449
520
  exit 1
450
521
  end
451
522
  end
452
-
523
+
453
524
  if options[:scheduled_date]
454
525
  begin
455
526
  parsed_date = Time.parse(options[:scheduled_date])
456
- if parsed_date < Time.now + 3600 # At least 1 hour in the future
457
- error "Scheduled date must be at least 1 hour in the future"
527
+ if parsed_date < Time.now + 3600 # At least 1 hour in the future
528
+ error 'Scheduled date must be at least 1 hour in the future'
458
529
  exit 1
459
530
  end
460
531
  ship_overrides['earliest_release_date'] = parsed_date.utc.iso8601
@@ -466,169 +537,166 @@ module Mysigner
466
537
  end
467
538
  rescue ArgumentError
468
539
  error "Invalid date format: #{options[:scheduled_date]}"
469
- say "Use ISO 8601 format, e.g., 2026-02-01T10:00:00Z", :yellow
540
+ say 'Use ISO 8601 format, e.g., 2026-02-01T10:00:00Z', :yellow
470
541
  exit 1
471
542
  end
472
543
  end
473
-
544
+
545
+ override_sources << { type: :inline, keys: ship_override_keys }
546
+
474
547
  submission = Upload::AppStoreSubmission.new(
475
548
  client,
476
549
  config.current_organization_id,
477
550
  {
478
551
  bundle_id: bundle_id,
479
- build_number: new_build['build_number'] # Use the specific build we found
552
+ build_number: new_build['build_number'] # Use the specific build we found
480
553
  },
481
554
  metadata_overrides: ship_overrides,
482
- override_sources: [{ type: :inline, keys: ship_override_keys }]
555
+ override_sources: override_sources
483
556
  )
484
-
557
+
485
558
  submission_result = submission.submit_for_review!(automation: automation)
486
559
  timings[:submission] = Time.now - submission_start
487
560
  end
488
-
561
+
489
562
  timings[:total] = Time.now - overall_start
490
563
 
491
564
  # SUCCESS SUMMARY!
492
- say ""
493
- say "=" * 80, :green
565
+ say ''
566
+ say '=' * 80, :green
494
567
  if is_appstore
495
568
  if submission_result && submission_result[:automation][:submitted]
496
- say "🎉 SUCCESS! Your app is submitted for App Store review!", :green
569
+ say '🎉 SUCCESS! Your app is submitted for App Store review!', :green
497
570
  else
498
- say "🎉 SUCCESS! Your app is uploaded to App Store Connect!", :green
571
+ say '🎉 SUCCESS! Your app is uploaded to App Store Connect!', :green
499
572
  end
500
573
  else
501
- say "🎉 SUCCESS! Your app is on TestFlight!", :green
574
+ say '🎉 SUCCESS! Your app is on TestFlight!', :green
502
575
  end
503
- say "=" * 80, :green
504
- say ""
505
-
576
+ say '=' * 80, :green
577
+ say ''
578
+
506
579
  # Summary table
507
- say "📊 Summary", :bold
508
- say ""
580
+ say '📊 Summary', :bold
581
+ say ''
509
582
  say " Project: #{project_name}"
510
583
  say " Bundle ID: #{bundle_id}"
511
584
  say " Target: #{target_name}"
512
585
  say " IPA Size: #{format_bytes(File.size(ipa_path))}"
513
- say ""
586
+ say ''
514
587
  if is_appstore && options[:submit_for_review]
515
588
  poll_msg = options[:wait] ? "every #{automation.poll_interval}s" : 'skipped (--no-wait)'
516
589
  say " ASC Polling: #{poll_msg}"
517
590
  say " ASC Timeout: #{format_duration(options[:asc_timeout_seconds])}" if options[:asc_timeout_seconds]
518
591
  end
519
-
592
+
520
593
  # Timing breakdown
521
- say "⏱️ Time Breakdown", :bold
522
- say ""
594
+ say '⏱️ Time Breakdown', :bold
595
+ say ''
523
596
  say " Build: #{format_duration(timings[:build])}"
524
597
  say " Export: #{format_duration(timings[:export])}"
525
598
  say " Upload: #{format_duration(timings[:upload])}"
526
- if timings[:submission]
527
- say " Submission: #{format_duration(timings[:submission])}"
528
- end
529
- say " " + "-" * 30
599
+ say " Submission: #{format_duration(timings[:submission])}" if timings[:submission]
600
+ say " #{'-' * 30}"
530
601
  say " Total: #{format_duration(timings[:total])}", :bold
531
- say ""
532
-
602
+ say ''
603
+
533
604
  # Files created
534
- say "📁 Files Created", :bold
535
- say ""
605
+ say '📁 Files Created', :bold
606
+ say ''
536
607
  say " Archive: #{archive_path}"
537
608
  say " IPA: #{ipa_path}"
538
- say ""
539
-
609
+ say ''
610
+
540
611
  # Next steps
541
- say "🔮 Next Steps", :bold
542
- say ""
612
+ say '🔮 Next Steps', :bold
613
+ say ''
543
614
  if is_appstore
544
615
  if submission_result && submission_result[:automation][:submitted]
545
- say " ✓ Your build is submitted for App Store review!", :green
546
- say ""
547
- say " Monitor review status:", :cyan
548
- say " https://appstoreconnect.apple.com/apps", :green
616
+ say ' ✓ Your build is submitted for App Store review!', :green
617
+ say ''
618
+ say ' Monitor review status:', :cyan
619
+ say ' https://appstoreconnect.apple.com/apps', :green
549
620
  else
550
- say " ⚠️ Submission completed but not submitted", :yellow
551
- say " (May need release config in My Signer dashboard)", :yellow
552
- say ""
553
- say " Or submit manually:", :cyan
554
- say " mysigner submit", :green
621
+ say ' ⚠️ Submission completed but not submitted', :yellow
622
+ say ' (May need release config in My Signer dashboard)', :yellow
623
+ say ''
624
+ say ' Or submit manually:', :cyan
625
+ say ' mysigner submit', :green
555
626
  end
556
627
  else
557
- say " 1. Wait 5-15 minutes for Apple to process your build"
558
- say " 2. Open App Store Connect:"
559
- say " https://appstoreconnect.apple.com/apps"
560
- say " 3. Add testers and distribute via TestFlight"
628
+ say ' 1. Wait 5-15 minutes for Apple to process your build'
629
+ say ' 2. Open App Store Connect:'
630
+ say ' https://appstoreconnect.apple.com/apps'
631
+ say ' 3. Add testers and distribute via TestFlight'
561
632
  end
562
- say ""
633
+ say ''
563
634
  rescue MetadataFileError => e
564
- say ""
565
- say "=" * 80, :red
566
- say "✗ Ship Failed", :red
567
- say "=" * 80, :red
568
- say ""
635
+ say ''
636
+ say '=' * 80, :red
637
+ say '✗ Ship Failed', :red
638
+ say '=' * 80, :red
639
+ say ''
569
640
  say "Error: #{e.message}", :red
570
- say ""
641
+ say ''
571
642
  exit 1
572
643
  rescue Build::Executor::BuildError => e
573
644
  # Analyze build errors and show helpful suggestions
574
- say ""
645
+ say ''
575
646
 
576
647
  if defined?(executor) && executor.respond_to?(:build_errors)
577
648
  require_relative '../build/error_analyzer'
578
649
  analyzer = Build::ErrorAnalyzer.new(executor.build_errors)
579
650
 
580
- if analyzer.any_issues?
581
- say analyzer.format_suggestions, :cyan
582
- end
651
+ say analyzer.format_suggestions, :cyan if analyzer.any_issues?
583
652
  end
584
653
 
585
- say "=" * 80, :red
586
- say "✗ Ship Failed", :red
587
- say "=" * 80, :red
588
- say ""
654
+ say '=' * 80, :red
655
+ say '✗ Ship Failed', :red
656
+ say '=' * 80, :red
657
+ say ''
589
658
  say "Error: #{e.message}", :red
590
- say ""
659
+ say ''
591
660
 
592
- if archive_path && File.exist?(archive_path)
593
- say "Archive saved at: #{archive_path}", :yellow
594
- end
661
+ say "Archive saved at: #{archive_path}", :yellow if archive_path && File.exist?(archive_path)
595
662
 
596
663
  exit 1
597
664
  rescue Upload::AppStoreAutomation::AutomationError => e
598
665
  # Use enhanced error handler for App Store automation errors
599
666
  handle_apple_api_error(e, context: {
600
- title: 'App Store Automation Failed',
601
- archive_path: archive_path,
602
- ipa_path: ipa_path,
603
- bundle_id: defined?(bundle_id) ? bundle_id : nil
604
- })
667
+ title: 'App Store Automation Failed',
668
+ archive_path: archive_path,
669
+ ipa_path: ipa_path,
670
+ bundle_id: defined?(bundle_id) ? bundle_id : nil
671
+ })
605
672
  exit 1
606
673
  rescue Mysigner::ClientError => e
607
674
  # Handle API client errors with actionable suggestions
608
675
  handle_apple_api_error(e, context: {
609
- title: 'API Error',
610
- archive_path: archive_path,
611
- ipa_path: ipa_path
612
- })
676
+ title: 'API Error',
677
+ archive_path: archive_path,
678
+ ipa_path: ipa_path
679
+ })
613
680
  exit 1
614
- rescue => e
615
- say ""
616
- say "=" * 80, :red
617
- say "✗ Ship Failed", :red
618
- say "=" * 80, :red
619
- say ""
681
+ rescue Mysigner::Upload::AscRestUploader::BuildVersionConflictError => e
682
+ say ''
683
+ say "✗ #{e.message}", :red
684
+ say ''
685
+ exit 1
686
+ rescue StandardError => e
687
+ say ''
688
+ say '=' * 80, :red
689
+ say '✗ Ship Failed', :red
690
+ say '=' * 80, :red
691
+ say ''
620
692
  say "Error: #{e.message}", :red
621
- say ""
693
+ say ''
622
694
 
623
695
  # Try to show actionable suggestions for unknown errors
624
696
  show_actionable_suggestions(e.message, platform: :ios)
625
697
 
626
- if archive_path && File.exist?(archive_path)
627
- say "Archive saved at: #{archive_path}", :yellow
628
- end
629
- if ipa_path && File.exist?(ipa_path)
630
- say "IPA saved at: #{ipa_path}", :yellow
631
- end
698
+ say "Archive saved at: #{archive_path}", :yellow if archive_path && File.exist?(archive_path)
699
+ say "IPA saved at: #{ipa_path}", :yellow if ipa_path && File.exist?(ipa_path)
632
700
 
633
701
  show_debug_info(e) if ENV['DEBUG']
634
702
  exit 1
@@ -640,7 +708,7 @@ module Mysigner
640
708
  def ship_android(track)
641
709
  config = load_config
642
710
  client = create_client(config)
643
-
711
+
644
712
  overall_start = Time.now
645
713
  timings = {}
646
714
  aab_path = nil
@@ -655,89 +723,89 @@ module Mysigner
655
723
  track_label = track_labels[track] || track.capitalize
656
724
 
657
725
  say "🤖 My Signer - Ship to Google Play (#{track_label})", :cyan
658
- say "=" * 80, :cyan
659
- say ""
660
- say "This will:", :bold
661
- say " 1️⃣ Detect and build your Android project"
662
- say " 2️⃣ Sign with your keystore"
726
+ say '=' * 80, :cyan
727
+ say ''
728
+ say 'This will:', :bold
729
+ say ' 1️⃣ Detect and build your Android project'
730
+ say ' 2️⃣ Sign with your keystore'
663
731
  say " 3️⃣ Upload to Google Play (#{track} track)"
664
- say ""
665
- say "⏱️ Estimated time: 3-10 minutes", :yellow
666
- say ""
732
+ say ''
733
+ say '⏱️ Estimated time: 3-10 minutes', :yellow
734
+ say ''
667
735
 
668
736
  begin
669
737
  # STEP 1: Detect and build
670
- say "=" * 80, :cyan
671
- say "[1/3] Building Android App Bundle (AAB)", :cyan
672
- say "=" * 80, :cyan
673
- say ""
738
+ say '=' * 80, :cyan
739
+ say '[1/3] Building Android App Bundle (AAB)', :cyan
740
+ say '=' * 80, :cyan
741
+ say ''
674
742
 
675
743
  build_start = Time.now
676
744
 
677
745
  # Detect Android project
678
746
  project_info = Build::Detector.detect_android
679
-
747
+
680
748
  framework_label = case project_info[:framework]
681
- when :capacitor then "Capacitor/Ionic"
682
- when :react_native then "React Native"
683
- when :flutter then "Flutter"
684
- else "Native Android"
685
- end
686
-
749
+ when :capacitor then 'Capacitor/Ionic'
750
+ when :react_native then 'React Native'
751
+ when :flutter then 'Flutter'
752
+ else 'Native Android'
753
+ end
754
+
687
755
  say "✓ Found: Android project (#{framework_label})", :green
688
- say ""
756
+ say ''
689
757
 
690
758
  # Parse project
691
759
  require_relative '../build/android_parser'
692
760
  parser = Build::AndroidParser.new(project_info)
693
-
761
+
694
762
  package_name = options[:package_name] || parser.application_id
695
763
  local_version_code = parser.version_code.to_i
696
764
  version_name = parser.version_name
697
-
765
+
698
766
  # Check highest version code from API and auto-increment if needed
699
767
  highest_version_code = fetch_android_highest_version_code(client, config, package_name)
700
768
  version_code = local_version_code
701
769
  version_code_override = nil
702
-
770
+
703
771
  if highest_version_code && local_version_code <= highest_version_code
704
772
  version_code = highest_version_code + 1
705
773
  version_code_override = version_code
706
774
  say "📦 Package: #{package_name}", :cyan
707
775
  say "🔢 Version: #{version_name} (#{local_version_code} → #{version_code})", :cyan
708
776
  say " ↳ Auto-incremented (#{highest_version_code} already on Play Store)", :yellow
709
-
777
+
710
778
  # For Expo projects, need to regenerate android folder with new versionCode
711
779
  # since versionCode is hardcoded by expo prebuild
712
780
  if expo_project?(Dir.pwd)
713
- say ""
781
+ say ''
714
782
  say "🔄 Regenerating android folder with version code #{version_code}...", :yellow
715
783
  regenerate_expo_android(Dir.pwd, version_code)
716
784
  # Re-detect after regeneration
717
785
  project_info = Build::Detector.detect_android
718
786
  parser = Build::AndroidParser.new(project_info)
719
- version_code_override = nil # No longer need override, it's baked in
720
- say "✓ Android folder regenerated", :green
787
+ version_code_override = nil # No longer need override, it's baked in
788
+ say '✓ Android folder regenerated', :green
721
789
  end
722
790
  else
723
791
  say "📦 Package: #{package_name}", :cyan
724
792
  say "🔢 Version: #{version_name} (#{version_code})", :cyan
725
793
  end
726
- say "⏱️ Estimated: 2-5 minutes", :yellow
727
- say ""
794
+ say '⏱️ Estimated: 2-5 minutes', :yellow
795
+ say ''
728
796
 
729
797
  # STEP 2: Get keystore and sign
730
- say "=" * 80, :cyan
731
- say "[2/3] Signing with Keystore", :cyan
732
- say "=" * 80, :cyan
733
- say ""
798
+ say '=' * 80, :cyan
799
+ say '[2/3] Signing with Keystore', :cyan
800
+ say '=' * 80, :cyan
801
+ say ''
734
802
 
735
803
  # Fetch keystore from API (prefer app-specific, fallback to org-wide)
736
- say "🔐 Fetching keystore from My Signer...", :yellow
737
-
804
+ say '🔐 Fetching keystore from My Signer...', :yellow
805
+
738
806
  require_relative '../signing/keystore_manager'
739
807
  keystore_manager = Signing::KeystoreManager.new(client, config.current_organization_id)
740
-
808
+
741
809
  # Try to find the app to get app-specific + org-wide keystores
742
810
  app_id = nil
743
811
  begin
@@ -745,46 +813,46 @@ module Mysigner
745
813
  apps = response[:data]['android_apps'] || []
746
814
  app = apps.find { |a| a['package_name'] == package_name }
747
815
  app_id = app['id'] if app
748
- rescue
816
+ rescue StandardError
749
817
  # Continue without app ID
750
818
  end
751
-
819
+
752
820
  active_keystore = keystore_manager.active_keystore(android_app_id: app_id, include_secrets: true)
753
821
  unless active_keystore
754
- say ""
755
- say "✗ No active keystore found", :red
756
- say ""
757
- say "Quick fix:", :cyan
758
- say " 1. Upload a keystore: mysigner keystore upload", :green
759
- say " 2. Or configure in My Signer dashboard", :green
760
- say ""
822
+ say ''
823
+ say '✗ No active keystore found', :red
824
+ say ''
825
+ say 'Quick fix:', :cyan
826
+ say ' 1. Upload a keystore: mysigner keystore upload', :green
827
+ say ' 2. Or configure in My Signer dashboard', :green
828
+ say ''
761
829
  exit 1
762
830
  end
763
831
 
764
832
  say "✓ Using keystore: #{active_keystore['name']}", :green
765
-
833
+
766
834
  # Download keystore
767
835
  keystore_info = keystore_manager.get_or_download(active_keystore['id'])
768
836
  say "✓ Keystore ready at: #{keystore_info[:path]}", :green
769
- say ""
837
+ say ''
770
838
 
771
839
  # Get keystore credentials from API response
772
- keystore_password = active_keystore['keystore_password'] || ENV['MYSIGNER_KEYSTORE_PASSWORD']
840
+ keystore_password = active_keystore['keystore_password'] || ENV.fetch('MYSIGNER_KEYSTORE_PASSWORD', nil)
773
841
  key_password = active_keystore['key_password'] || ENV['MYSIGNER_KEY_PASSWORD'] || keystore_password
774
842
  key_alias = active_keystore['key_alias']
775
843
 
776
844
  unless keystore_password
777
- say "⚠️ Keystore password not found in My Signer", :yellow
778
- say " Upload your keystore with password: mysigner keystore upload FILE", :yellow
779
- keystore_password = ask("Keystore password:", echo: false)
780
- say ""
845
+ say '⚠️ Keystore password not found in My Signer', :yellow
846
+ say ' Upload your keystore with password: mysigner keystore upload FILE', :yellow
847
+ keystore_password = ask('Keystore password:', echo: false)
848
+ say ''
781
849
  key_password ||= keystore_password
782
850
  end
783
851
 
784
852
  # Build AAB
785
853
  require_relative '../build/android_executor'
786
854
  executor = Build::AndroidExecutor.new(project_info, parser)
787
-
855
+
788
856
  aab_path = executor.build_aab!(
789
857
  variant: 'release',
790
858
  keystore_path: keystore_info[:path],
@@ -795,65 +863,96 @@ module Mysigner
795
863
  )
796
864
 
797
865
  timings[:build] = Time.now - build_start
798
-
799
- say ""
866
+
867
+ say ''
800
868
  say "✓ Build complete in #{format_duration(timings[:build])}", :green
801
869
  say "📦 AAB: #{aab_path}", :cyan
802
- say ""
870
+ say ''
803
871
 
804
872
  # STEP 3: Upload to Google Play
805
- say "=" * 80, :cyan
806
- say "[3/3] Uploading to Google Play", :cyan
807
- say "=" * 80, :cyan
808
- say ""
873
+ say '=' * 80, :cyan
874
+ say '[3/3] Uploading to Google Play', :cyan
875
+ say '=' * 80, :cyan
876
+ say ''
809
877
 
810
878
  upload_start = Time.now
811
879
 
812
- # Fetch Google Play credentials from API
813
- say "🔐 Fetching Google Play credentials...", :yellow
814
-
815
- org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
816
- org_data = org_response[:data]
817
-
818
- unless org_data['google_play_configured']
819
- say ""
820
- say "✗ Google Play credentials not configured", :red
821
- say ""
822
- say "Quick fix:", :cyan
823
- say " Configure Google Play credentials in My Signer dashboard", :green
824
- say ""
825
- say "Or configure in My Signer dashboard", :yellow
826
- say ""
880
+ # Phase 0: mint short-lived OAuth2 access token server-side.
881
+ # Service-account JSON never leaves the server.
882
+ say '🔐 Requesting Google Play access token...', :yellow
883
+
884
+ begin
885
+ token_response = client.post(
886
+ "/api/v1/organizations/#{config.current_organization_id}/credentials/google_play/access_token"
887
+ )
888
+ access_token = token_response[:data]['access_token']
889
+ rescue Mysigner::NotFoundError, Mysigner::ValidationError
890
+ say ''
891
+ say '✗ Google Play credentials not configured', :red
892
+ say ''
893
+ say 'Quick fix:', :cyan
894
+ say ' Configure Google Play credentials in My Signer dashboard', :green
895
+ say ''
827
896
  exit 1
828
897
  end
829
898
 
830
- service_account_json = org_data['google_play_service_account']
831
-
832
- unless service_account_json
833
- say "✗ Error: Service account JSON not found", :red
899
+ if access_token.nil? || access_token.to_s.empty?
900
+ say '✗ Error: Failed to mint Google Play access token', :red
834
901
  exit 1
835
902
  end
836
-
837
- say "✓ Credentials loaded", :green
838
- say ""
839
903
 
840
- # Upload to Google Play
904
+ say '✓ Access token minted (valid ~1 hour)', :green
905
+ say ''
906
+
907
+ # Phase 0: fetch Android CLI Defaults from the dashboard
908
+ # (android_apps.cli_defaults JSONB). Fields here act as base
909
+ # values; explicit CLI flags below layer on top.
910
+ android_defaults = fetch_android_release_defaults(client, config, package_name)
911
+ if android_defaults
912
+ say "✓ Loaded CLI Defaults for #{package_name}", :green
913
+ say ''
914
+ end
915
+
916
+ # Upload to Google Play with bare bearer token
841
917
  require_relative '../upload/play_store_uploader'
842
-
918
+
919
+ # Merge release notes: flag > defaults.release_notes (Hash)
843
920
  release_notes = nil
844
921
  if options[:release_notes]
845
922
  release_notes = { 'en-US' => options[:release_notes] }
923
+ elsif android_defaults && android_defaults['release_notes'].is_a?(Hash) && android_defaults['release_notes'].any?
924
+ release_notes = android_defaults['release_notes']
846
925
  end
847
926
 
927
+ # Effective track: positional arg wins; defaults only kick in
928
+ # if the user left it implicit (which `ship android` currently
929
+ # doesn't allow — track is required — but kept here for
930
+ # symmetry with submit_android and future-proofing).
931
+ effective_track = track.presence || android_defaults&.dig('default_track') || 'internal'
932
+
933
+ status = android_defaults&.dig('default_status')
934
+ user_fraction = android_defaults&.dig('default_user_fraction')
935
+ in_app_update_priority = android_defaults&.dig('default_in_app_update_priority')
936
+ release_name = android_defaults&.dig('release_name')
937
+ country_targeting = android_defaults&.dig('country_targeting')
938
+ changes_not_sent_for_review = android_defaults&.key?('changes_not_sent_for_review') ? android_defaults['changes_not_sent_for_review'] : nil
939
+ country_targeting = country_targeting.transform_keys(&:to_sym) if country_targeting.is_a?(Hash)
940
+
848
941
  uploader = Upload::PlayStoreUploader.new(
849
942
  aab_path: aab_path,
850
- service_account_json: service_account_json,
943
+ access_token: access_token,
851
944
  package_name: package_name
852
945
  )
853
-
854
- result = uploader.upload!(
855
- track: track,
856
- release_notes: release_notes
946
+
947
+ uploader.upload!(
948
+ track: effective_track,
949
+ release_notes: release_notes,
950
+ user_fraction: user_fraction,
951
+ status: status,
952
+ in_app_update_priority: in_app_update_priority,
953
+ release_name: release_name,
954
+ country_targeting: country_targeting,
955
+ changes_not_sent_for_review: changes_not_sent_for_review
857
956
  )
858
957
 
859
958
  timings[:upload] = Time.now - upload_start
@@ -866,7 +965,7 @@ module Mysigner
866
965
  "/api/v1/organizations/#{config.current_organization_id}/android_keystores/#{active_keystore['id']}/link_to_app",
867
966
  body: { package_name: package_name }
868
967
  )
869
- rescue => e
968
+ rescue StandardError => e
870
969
  # Non-fatal, continue
871
970
  end
872
971
  end
@@ -875,139 +974,158 @@ module Mysigner
875
974
  save_android_build_record(client, config, package_name, version_code, version_name)
876
975
 
877
976
  # SUCCESS SUMMARY
878
- say ""
879
- say "=" * 80, :green
977
+ say ''
978
+ say '=' * 80, :green
880
979
  say "🎉 SUCCESS! Your app is on Google Play (#{track} track)!", :green
881
- say "=" * 80, :green
882
- say ""
980
+ say '=' * 80, :green
981
+ say ''
883
982
 
884
- say "📊 Summary", :bold
885
- say ""
983
+ say '📊 Summary', :bold
984
+ say ''
886
985
  say " Package: #{package_name}"
887
986
  say " Version: #{version_name} (#{version_code})"
888
987
  say " Track: #{track}"
889
988
  say " AAB Size: #{format_bytes(File.size(aab_path))}"
890
- say ""
989
+ say ''
891
990
 
892
- say "⏱️ Time Breakdown", :bold
893
- say ""
991
+ say '⏱️ Time Breakdown', :bold
992
+ say ''
894
993
  say " Build: #{format_duration(timings[:build])}"
895
994
  say " Upload: #{format_duration(timings[:upload])}"
896
- say " " + "-" * 30
995
+ say " #{'-' * 30}"
897
996
  say " Total: #{format_duration(timings[:total])}", :bold
898
- say ""
997
+ say ''
899
998
 
900
- say "📁 Files Created", :bold
901
- say ""
999
+ say '📁 Files Created', :bold
1000
+ say ''
902
1001
  say " AAB: #{aab_path}"
903
- say ""
1002
+ say ''
904
1003
 
905
- say "🔮 Next Steps", :bold
906
- say ""
1004
+ say '🔮 Next Steps', :bold
1005
+ say ''
907
1006
  case track
908
1007
  when 'internal'
909
- say " 1. Add internal testers in Google Play Console"
910
- say " 2. Testers will receive the build automatically"
1008
+ say ' 1. Add internal testers in Google Play Console'
1009
+ say ' 2. Testers will receive the build automatically'
911
1010
  when 'alpha', 'beta'
912
- say " 1. Review the build in Google Play Console"
913
- say " 2. Promote to #{track == 'alpha' ? 'beta or ' : ''}production when ready"
1011
+ say ' 1. Review the build in Google Play Console'
1012
+ say " 2. Promote to #{'beta or ' if track == 'alpha'}production when ready"
914
1013
  when 'production'
915
- say " 1. Review is pending in Google Play Console"
916
- say " 2. Once approved, users will receive the update"
1014
+ say ' 1. Review is pending in Google Play Console'
1015
+ say ' 2. Once approved, users will receive the update'
917
1016
  end
918
- say ""
919
- say " Google Play Console: https://play.google.com/console", :green
920
- say ""
921
-
1017
+ say ''
1018
+ say ' Google Play Console: https://play.google.com/console', :green
1019
+ say ''
922
1020
  rescue Build::Detector::NoProjectError => e
923
- say ""
924
- say "=" * 80, :red
925
- say "✗ Ship Failed", :red
926
- say "=" * 80, :red
927
- say ""
1021
+ say ''
1022
+ say '=' * 80, :red
1023
+ say '✗ Ship Failed', :red
1024
+ say '=' * 80, :red
1025
+ say ''
928
1026
  say "Error: #{e.message}", :red
929
- say ""
930
- say "💡 No Android Project Found: How to fix", :cyan
931
- say ""
1027
+ say ''
1028
+ say '💡 No Android Project Found: How to fix', :cyan
1029
+ say ''
932
1030
  say " → Make sure you're in an Android project directory", :yellow
933
- say " → Check for build.gradle or build.gradle.kts file", :yellow
934
- say " → For React Native: cd android && check build.gradle exists", :yellow
935
- say " → For Flutter: check android/app/build.gradle exists", :yellow
936
- say ""
1031
+ say ' → Check for build.gradle or build.gradle.kts file', :yellow
1032
+ say ' → For React Native: cd android && check build.gradle exists', :yellow
1033
+ say ' → For Flutter: check android/app/build.gradle exists', :yellow
1034
+ say ''
937
1035
  exit 1
938
1036
  rescue Upload::PlayStoreUploader::PartialUploadError => e
939
1037
  # AAB was uploaded but track assignment/commit failed
940
1038
  # Save build record to prevent version conflicts on retry
941
- say ""
942
- say "=" * 80, :red
943
- say "✗ Partial Upload - Track Assignment Failed", :red
944
- say "=" * 80, :red
945
- say ""
1039
+ say ''
1040
+ say '=' * 80, :red
1041
+ say '✗ Partial Upload - Track Assignment Failed', :red
1042
+ say '=' * 80, :red
1043
+ say ''
946
1044
  say "Error: #{e.message}", :red
947
- say ""
948
-
1045
+ say ''
1046
+
949
1047
  # Save build record even on partial failure (AAB is on Play Store)
950
1048
  if e.version_code
951
1049
  save_android_build_record(client, config, package_name, e.version_code, version_name)
952
1050
  say "📝 Build v#{e.version_code} recorded (prevents version conflicts on retry)", :yellow
953
- say ""
1051
+ say ''
954
1052
  end
955
-
1053
+
956
1054
  # Show track setup suggestions
957
1055
  show_track_not_setup_suggestions(track)
958
-
959
- if aab_path && File.exist?(aab_path)
960
- say "📦 AAB saved at: #{aab_path}", :yellow
961
- end
962
- say ""
1056
+
1057
+ say "📦 AAB saved at: #{aab_path}", :yellow if aab_path && File.exist?(aab_path)
1058
+ say ''
963
1059
  exit 1
964
1060
  rescue Upload::PlayStoreUploader::UploadError => e
965
1061
  # Use enhanced error handler for Google Play errors
966
1062
  handle_android_api_error(e, context: {
967
- title: 'Google Play Upload Failed',
968
- aab_path: aab_path,
969
- package_name: package_name,
970
- track: track
971
- })
1063
+ title: 'Google Play Upload Failed',
1064
+ aab_path: aab_path,
1065
+ package_name: package_name,
1066
+ track: track
1067
+ })
972
1068
  exit 1
973
1069
  rescue Mysigner::ClientError => e
974
1070
  # Handle My Signer API client errors
975
1071
  handle_android_api_error(e, context: {
976
- title: 'API Error',
977
- aab_path: aab_path,
978
- package_name: package_name
979
- })
1072
+ title: 'API Error',
1073
+ aab_path: aab_path,
1074
+ package_name: package_name
1075
+ })
980
1076
  exit 1
981
- rescue => e
982
- say ""
983
- say "=" * 80, :red
984
- say "✗ Ship Failed", :red
985
- say "=" * 80, :red
986
- say ""
1077
+ rescue StandardError => e
1078
+ say ''
1079
+ say '=' * 80, :red
1080
+ say '✗ Ship Failed', :red
1081
+ say '=' * 80, :red
1082
+ say ''
987
1083
  say "Error: #{e.message}", :red
988
- say ""
989
-
1084
+ say ''
1085
+
990
1086
  # Try to show actionable suggestions for unknown errors
991
1087
  show_actionable_suggestions(e.message, platform: :android)
992
-
993
- if aab_path && File.exist?(aab_path)
994
- say "📦 AAB saved at: #{aab_path}", :yellow
995
- end
996
-
1088
+
1089
+ say "📦 AAB saved at: #{aab_path}", :yellow if aab_path && File.exist?(aab_path)
1090
+
997
1091
  show_debug_info(e) if ENV['DEBUG']
998
1092
  exit 1
999
1093
  end
1000
1094
  end
1001
1095
 
1096
+ # Fetch Android CLI Defaults (android_apps.cli_defaults) for the
1097
+ # package_name. Returns nil if none configured or request fails —
1098
+ # ship proceeds with CLI-flag-only values in that case.
1099
+ def fetch_android_release_defaults(client, config, package_name)
1100
+ response = client.get(
1101
+ "/api/v1/organizations/#{config.current_organization_id}/android_releases",
1102
+ params: { package_name: package_name }
1103
+ )
1104
+ data = response[:data] if response[:success]
1105
+ return nil unless data.is_a?(Hash)
1106
+
1107
+ releases = data['android_releases']
1108
+ return nil unless releases.is_a?(Array) && releases.any?
1109
+
1110
+ releases.first
1111
+ rescue Mysigner::NotFoundError
1112
+ nil
1113
+ rescue StandardError => e
1114
+ # Non-fatal: log and proceed without defaults.
1115
+ puts "⚠️ Could not fetch Android CLI Defaults: #{e.message}" if ENV['MYSIGNER_VERBOSE'] == '1'
1116
+ nil
1117
+ end
1118
+
1002
1119
  # Fetch highest version code from API
1003
1120
  def fetch_android_highest_version_code(client, config, package_name)
1004
1121
  response = client.get("/api/v1/organizations/#{config.current_organization_id}/android_apps")
1005
1122
  apps = response[:data]['android_apps'] || []
1006
1123
  app = apps.find { |a| a['package_name'] == package_name }
1007
-
1124
+
1008
1125
  return app['highest_version_code'].to_i if app && app['highest_version_code']
1126
+
1009
1127
  nil
1010
- rescue => e
1128
+ rescue StandardError
1011
1129
  # Silently fail - we'll use local version
1012
1130
  nil
1013
1131
  end
@@ -1018,7 +1136,7 @@ module Mysigner
1018
1136
  response = client.get("/api/v1/organizations/#{config.current_organization_id}/android_apps")
1019
1137
  apps = response[:data]['android_apps'] || []
1020
1138
  app = apps.find { |a| a['package_name'] == package_name }
1021
-
1139
+
1022
1140
  unless app
1023
1141
  # App doesn't exist in MySigner yet - create it with a friendly name
1024
1142
  friendly_name = generate_app_name_from_package(package_name)
@@ -1034,7 +1152,7 @@ module Mysigner
1034
1152
  "/api/v1/organizations/#{config.current_organization_id}/android_apps/#{app['id']}/android_builds",
1035
1153
  body: { android_build: { version_code: version_code, version_name: version_name, status: 'completed' } }
1036
1154
  )
1037
- rescue => e
1155
+ rescue StandardError => e
1038
1156
  # Non-fatal - just log for debugging
1039
1157
  say "⚠️ Could not save build record: #{e.message}", :yellow if options[:verbose]
1040
1158
  end
@@ -1057,7 +1175,7 @@ module Mysigner
1057
1175
  config = load_config
1058
1176
  client = create_client(config)
1059
1177
 
1060
- valid_tracks = ['internal', 'alpha', 'beta', 'production']
1178
+ valid_tracks = %w[internal alpha beta production]
1061
1179
  unless valid_tracks.include?(track)
1062
1180
  error "Invalid Android track: #{track}"
1063
1181
  say "Valid tracks: #{valid_tracks.join(', ')}", :yellow
@@ -1073,13 +1191,13 @@ module Mysigner
1073
1191
  track_label = track_labels[track]
1074
1192
 
1075
1193
  say "📤 Promote to Google Play #{track_label}", :cyan
1076
- say "=" * 80, :cyan
1077
- say ""
1194
+ say '=' * 80, :cyan
1195
+ say ''
1078
1196
 
1079
1197
  begin
1080
1198
  # Get package name
1081
1199
  package_name = options[:package_name]
1082
-
1200
+
1083
1201
  unless package_name
1084
1202
  begin
1085
1203
  project_info = Build::Detector.detect_android
@@ -1087,41 +1205,47 @@ module Mysigner
1087
1205
  parser = Build::AndroidParser.new(project_info)
1088
1206
  package_name = parser.application_id
1089
1207
  say "✓ Detected package: #{package_name}", :green
1090
- rescue
1091
- error "Could not detect package name from project"
1092
- say ""
1093
- say "Please specify manually:", :yellow
1208
+ rescue StandardError
1209
+ error 'Could not detect package name from project'
1210
+ say ''
1211
+ say 'Please specify manually:', :yellow
1094
1212
  say " mysigner submit #{track} --platform android --package-name com.your.app", :cyan
1095
1213
  exit 1
1096
1214
  end
1097
1215
  end
1098
1216
 
1099
- say ""
1217
+ say ''
1100
1218
  say "📦 Package: #{package_name}", :cyan
1101
1219
  say "🎯 Target Track: #{track_label}", :cyan
1102
- say ""
1220
+ say ''
1103
1221
 
1104
- # Fetch Google Play credentials
1105
- say "🔐 Fetching Google Play credentials...", :yellow
1106
-
1107
- org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
1108
- org_data = org_response[:data]
1109
-
1110
- unless org_data['google_play_configured']
1111
- say ""
1112
- say "✗ Google Play credentials not configured", :red
1113
- say ""
1114
- say "Configure Google Play credentials in My Signer dashboard", :cyan
1222
+ # Phase 0: mint short-lived access token server-side; JSON stays on server
1223
+ say '🔐 Requesting Google Play access token...', :yellow
1224
+
1225
+ begin
1226
+ token_response = client.post(
1227
+ "/api/v1/organizations/#{config.current_organization_id}/credentials/google_play/access_token"
1228
+ )
1229
+ access_token = token_response[:data]['access_token']
1230
+ rescue Mysigner::NotFoundError, Mysigner::ValidationError
1231
+ say ''
1232
+ say '✗ Google Play credentials not configured', :red
1233
+ say ''
1234
+ say 'Configure Google Play credentials in My Signer dashboard', :cyan
1115
1235
  exit 1
1116
1236
  end
1117
1237
 
1118
- service_account_json = org_data['google_play_service_account']
1119
- say "✓ Credentials loaded", :green
1120
- say ""
1238
+ if access_token.nil? || access_token.to_s.empty?
1239
+ say '✗ Error: Failed to mint Google Play access token', :red
1240
+ exit 1
1241
+ end
1242
+
1243
+ say '✓ Access token minted', :green
1244
+ say ''
1121
1245
 
1122
1246
  # Get the latest build from the API
1123
- say "🔍 Finding builds in My Signer...", :yellow
1124
-
1247
+ say '🔍 Finding builds in My Signer...', :yellow
1248
+
1125
1249
  apps_response = client.get(
1126
1250
  "/api/v1/organizations/#{config.current_organization_id}/android_apps",
1127
1251
  params: { q: package_name }
@@ -1130,48 +1254,38 @@ module Mysigner
1130
1254
  app = apps.find { |a| a['package_name'] == package_name }
1131
1255
 
1132
1256
  unless app
1133
- say ""
1134
- say "⚠️ App not found in My Signer", :yellow
1135
- say ""
1136
- say "The app may not be synced yet. Try:", :cyan
1137
- say " mysigner sync android", :green
1138
- say ""
1257
+ say ''
1258
+ say '⚠️ App not found in My Signer', :yellow
1259
+ say ''
1260
+ say 'The app may not be synced yet. Try:', :cyan
1261
+ say ' mysigner sync android', :green
1262
+ say ''
1139
1263
  end
1140
1264
 
1141
1265
  # Use version code from option or prompt for it
1142
1266
  version_code = options[:version_code]
1143
-
1267
+
1144
1268
  unless version_code
1145
- say ""
1269
+ say ''
1146
1270
  say "Enter the version code to promote to #{track}:", :yellow
1147
- version_code = ask("Version code:")
1271
+ version_code = ask('Version code:')
1148
1272
  end
1149
1273
 
1150
- say ""
1274
+ say ''
1151
1275
  say "📤 Promoting version #{version_code} to #{track} track...", :cyan
1152
- say ""
1276
+ say ''
1153
1277
 
1154
1278
  # Use PlayStoreUploader to assign to track
1155
1279
  require_relative '../upload/play_store_uploader'
1156
-
1280
+
1157
1281
  release_notes = nil
1158
- if options[:release_notes]
1159
- release_notes = { 'en-US' => options[:release_notes] }
1160
- end
1282
+ release_notes = { 'en-US' => options[:release_notes] } if options[:release_notes]
1161
1283
 
1162
- # Create a minimal uploader just for track assignment
1163
- # We need to use the Google API directly for this
1164
- require 'googleauth'
1284
+ # Use the Google API directly with the bare bearer token for track assignment
1165
1285
  require 'google/apis/androidpublisher_v3'
1166
- require 'stringio'
1167
-
1168
- auth = Google::Auth::ServiceAccountCredentials.make_creds(
1169
- json_key_io: StringIO.new(service_account_json),
1170
- scope: 'https://www.googleapis.com/auth/androidpublisher'
1171
- )
1172
1286
 
1173
1287
  service = Google::Apis::AndroidpublisherV3::AndroidPublisherService.new
1174
- service.authorization = auth
1288
+ service.authorization = access_token
1175
1289
 
1176
1290
  # Create edit
1177
1291
  edit = service.insert_edit(package_name, Google::Apis::AndroidpublisherV3::AppEdit.new)
@@ -1198,33 +1312,32 @@ module Mysigner
1198
1312
  # Commit
1199
1313
  service.commit_edit(package_name, edit.id, changes_not_sent_for_review: true)
1200
1314
 
1201
- say ""
1202
- say "=" * 80, :green
1315
+ say ''
1316
+ say '=' * 80, :green
1203
1317
  say "✓ Successfully promoted to #{track} track!", :green
1204
- say "=" * 80, :green
1205
- say ""
1318
+ say '=' * 80, :green
1319
+ say ''
1206
1320
  say "📦 Package: #{package_name}"
1207
1321
  say "🔢 Version Code: #{version_code}"
1208
1322
  say "🎯 Track: #{track_label}"
1209
- say ""
1210
- say "View in Google Play Console:", :cyan
1211
- say " https://play.google.com/console", :green
1212
- say ""
1213
-
1323
+ say ''
1324
+ say 'View in Google Play Console:', :cyan
1325
+ say ' https://play.google.com/console', :green
1326
+ say ''
1214
1327
  rescue Google::Apis::ClientError => e
1215
- say ""
1216
- say "=" * 80, :red
1217
- say "✗ Promotion Failed", :red
1218
- say "=" * 80, :red
1219
- say ""
1328
+ say ''
1329
+ say '=' * 80, :red
1330
+ say '✗ Promotion Failed', :red
1331
+ say '=' * 80, :red
1332
+ say ''
1220
1333
  say "Google Play API error: #{e.message}", :red
1221
1334
  exit 1
1222
- rescue => e
1223
- say ""
1224
- say "=" * 80, :red
1225
- say "✗ Promotion Failed", :red
1226
- say "=" * 80, :red
1227
- say ""
1335
+ rescue StandardError => e
1336
+ say ''
1337
+ say '=' * 80, :red
1338
+ say '✗ Promotion Failed', :red
1339
+ say '=' * 80, :red
1340
+ say ''
1228
1341
  say "Error: #{e.message}", :red
1229
1342
  exit 1
1230
1343
  end
@@ -1236,7 +1349,7 @@ module Mysigner
1236
1349
 
1237
1350
  if opts[:metadata_file]
1238
1351
  file_overrides = load_metadata_file(opts[:metadata_file])
1239
- overrides = deep_merge_hashes(overrides, file_overrides)
1352
+ overrides = deep_merge_hashes(overrides, file_overrides)
1240
1353
  sources << {
1241
1354
  type: :file,
1242
1355
  path: File.expand_path(opts[:metadata_file]),
@@ -1258,16 +1371,12 @@ module Mysigner
1258
1371
  def load_metadata_file(path)
1259
1372
  expanded = File.expand_path(path)
1260
1373
 
1261
- unless File.exist?(expanded) && File.file?(expanded)
1262
- raise MetadataFileError, "Metadata file not found: #{expanded}"
1263
- end
1374
+ raise MetadataFileError, "Metadata file not found: #{expanded}" unless File.exist?(expanded) && File.file?(expanded)
1264
1375
 
1265
1376
  content = File.read(expanded)
1266
1377
  parsed = parse_metadata_content(content, expanded)
1267
1378
 
1268
- unless parsed.is_a?(Hash)
1269
- raise MetadataFileError, 'Metadata file must contain a JSON/YAML object at the top level'
1270
- end
1379
+ raise MetadataFileError, 'Metadata file must contain a JSON/YAML object at the top level' unless parsed.is_a?(Hash)
1271
1380
 
1272
1381
  stringify_keys(parsed)
1273
1382
  rescue Errno::EACCES => e
@@ -1278,11 +1387,9 @@ module Mysigner
1278
1387
  stripped = content.lstrip
1279
1388
 
1280
1389
  begin
1281
- if stripped.start_with?('---') || stripped.start_with?('- ')
1282
- return YAML.safe_load(content, aliases: true) || {}
1283
- end
1390
+ return YAML.safe_load(content, aliases: true) || {} if stripped.start_with?('---') || stripped.start_with?('- ')
1284
1391
 
1285
- return JSON.parse(content)
1392
+ JSON.parse(content)
1286
1393
  rescue JSON::ParserError
1287
1394
  begin
1288
1395
  YAML.safe_load(content, aliases: true) || {}
@@ -1301,10 +1408,10 @@ module Mysigner
1301
1408
  merged = base.dup
1302
1409
  overrides.each do |key, value|
1303
1410
  merged[key] = if merged[key].is_a?(Hash) && value.is_a?(Hash)
1304
- deep_merge_hashes(merged[key], value)
1305
- else
1306
- value
1307
- end
1411
+ deep_merge_hashes(merged[key], value)
1412
+ else
1413
+ value
1414
+ end
1308
1415
  end
1309
1416
  merged
1310
1417
  end
@@ -1348,7 +1455,7 @@ module Mysigner
1348
1455
  say " ASC Polling: completed in #{format_duration(wait[:elapsed_seconds])}"
1349
1456
  end
1350
1457
  else
1351
- say " ASC Polling: skipped (--no-wait)", :yellow
1458
+ say ' ASC Polling: skipped (--no-wait)', :yellow
1352
1459
  end
1353
1460
 
1354
1461
  if result[:submitted]
@@ -1361,81 +1468,84 @@ module Mysigner
1361
1468
  keys = Array(source[:keys]).join(', ')
1362
1469
  case source[:type]
1363
1470
  when :inline
1364
- say " Overrides: CLI flag#{keys.empty? ? '' : " (#{keys})"}"
1471
+ say " Overrides: CLI flag#{" (#{keys})" unless keys.empty?}"
1365
1472
  when :file
1366
- say " Overrides: #{File.basename(source[:path])}#{keys.empty? ? '' : " (#{keys})"}"
1473
+ say " Overrides: #{File.basename(source[:path])}#{" (#{keys})" unless keys.empty?}"
1367
1474
  end
1368
1475
  end
1369
1476
  end
1370
1477
  end
1371
1478
 
1372
- desc "build", "Build .xcarchive only (advanced - most users should use 'ship')"
1373
- method_option :configuration, aliases: '-c', default: 'Release', desc: 'Build configuration (Debug, Release, etc.)'
1479
+ desc 'build', "Build .xcarchive only (advanced - most users should use 'ship')"
1480
+ method_option :configuration, aliases: '-c', default: 'Release',
1481
+ desc: 'Build configuration (Debug, Release, etc.)'
1374
1482
  method_option :target, aliases: '-t', desc: 'Target to build (auto-detect if not specified)'
1375
1483
  method_option :scheme, aliases: '-s', desc: 'Scheme to build (defaults to target name)'
1376
1484
  method_option :type, default: 'appstore', desc: 'Build type: development, adhoc, appstore, enterprise'
1377
1485
  method_option :team, desc: 'Development team ID (overrides project setting)'
1378
1486
  method_option :bundle_id, aliases: '-b', desc: 'Bundle ID (overrides project setting)'
1379
- method_option :skip_extensions, type: :boolean, default: false, desc: 'Skip extension targets (useful when extensions are not configured)'
1487
+ method_option :skip_extensions, type: :boolean, default: false,
1488
+ desc: 'Skip extension targets (useful when extensions are not configured)'
1380
1489
  def build
1381
1490
  config = load_config
1382
1491
  client = create_client(config)
1383
1492
 
1384
- say "🔍 Detecting project...", :cyan
1385
- say ""
1493
+ say '🔍 Detecting project...', :cyan
1494
+ say ''
1386
1495
 
1387
1496
  begin
1388
1497
  # Detect project
1389
1498
  project_info = Build::Detector.detect
1390
-
1499
+
1391
1500
  framework_label = case project_info[:framework]
1392
- when :capacitor then "Capacitor/Ionic"
1393
- when :react_native then "React Native"
1394
- when :flutter then "Flutter"
1395
- else "Native iOS"
1396
- end
1397
-
1501
+ when :capacitor then 'Capacitor/Ionic'
1502
+ when :react_native then 'React Native'
1503
+ when :flutter then 'Flutter'
1504
+ else 'Native iOS'
1505
+ end
1506
+
1398
1507
  say "✓ Found: #{File.basename(project_info[:path])} (#{framework_label})", :green
1399
- say ""
1508
+ say ''
1400
1509
 
1401
1510
  # Parse project
1402
1511
  parser = Build::Parser.new(project_info)
1403
-
1512
+
1404
1513
  # Check if this is a buildable app (not framework/library)
1405
1514
  main_product_type = parser.product_type
1406
- unless [:app, :unknown].include?(main_product_type)
1515
+ unless %i[app unknown].include?(main_product_type)
1407
1516
  error "Cannot build #{main_product_type} projects for TestFlight"
1408
- say ""
1409
- say "My Signer builds iOS/macOS/tvOS apps for distribution.", :yellow
1517
+ say ''
1518
+ say 'My Signer builds iOS/macOS/tvOS apps for distribution.', :yellow
1410
1519
  say "This project builds a #{main_product_type}, not an app.", :yellow
1411
- say ""
1520
+ say ''
1412
1521
  exit 1
1413
1522
  end
1414
-
1523
+
1415
1524
  # Check for multiple apps and prompt user if needed
1416
1525
  if parser.has_multiple_apps? && !options[:target]
1417
1526
  app_targets = parser.app_targets
1418
- say "Multiple apps found in project:", :yellow
1527
+ say 'Multiple apps found in project:', :yellow
1419
1528
  app_targets.each_with_index do |target, i|
1420
1529
  say " #{i + 1}. #{target.name}", :cyan
1421
1530
  end
1422
- say ""
1423
-
1424
- choice = ask("Select app to build (1-#{app_targets.count}):", limited_to: (1..app_targets.count).map(&:to_s))
1531
+ say ''
1532
+
1533
+ choice = ask("Select app to build (1-#{app_targets.count}):",
1534
+ limited_to: (1..app_targets.count).map(&:to_s))
1425
1535
  target_name = app_targets[choice.to_i - 1].name
1426
1536
  else
1427
1537
  target_name = options[:target] || parser.main_target.name
1428
1538
  end
1429
-
1539
+
1430
1540
  say "🎯 Target: #{target_name}", :cyan
1431
-
1541
+
1432
1542
  # Show platform if not iOS
1433
1543
  platform = parser.target_platform(target_name)
1434
1544
  unless platform == :ios
1435
1545
  platform_label = platform.to_s.upcase
1436
1546
  say "📱 Platform: #{platform_label}", :cyan
1437
1547
  end
1438
-
1548
+
1439
1549
  # Show extensions if any
1440
1550
  if parser.has_extensions?
1441
1551
  ext_count = parser.extension_targets.count
@@ -1445,9 +1555,9 @@ module Mysigner
1445
1555
  say "🧩 Extensions: #{ext_count} (will be included in build)", :cyan
1446
1556
  end
1447
1557
  end
1448
-
1558
+
1449
1559
  bundle_id = options[:bundle_id] || parser.bundle_id(target_name, options[:configuration])
1450
-
1560
+
1451
1561
  # Validate bundle ID format if overridden
1452
1562
  if options[:bundle_id]
1453
1563
  if bundle_id =~ /\$\(|\$\{/
@@ -1455,74 +1565,72 @@ module Mysigner
1455
1565
  exit 1
1456
1566
  elsif bundle_id !~ /^[a-zA-Z0-9.-]+$/
1457
1567
  error "Invalid bundle ID format: #{bundle_id}"
1458
- say "Bundle IDs must contain only letters, numbers, hyphens, and periods", :yellow
1568
+ say 'Bundle IDs must contain only letters, numbers, hyphens, and periods', :yellow
1459
1569
  exit 1
1460
1570
  end
1461
1571
  end
1462
-
1463
- say "📦 Bundle ID: #{bundle_id}#{options[:bundle_id] ? ' (overridden)' : ''}", :cyan
1572
+
1573
+ say "📦 Bundle ID: #{bundle_id}#{' (overridden)' if options[:bundle_id]}", :cyan
1464
1574
  say "⚙️ Configuration: #{options[:configuration]}", :cyan
1465
-
1575
+
1466
1576
  # Check signing style
1467
1577
  sign_style = parser.code_sign_style(target_name, options[:configuration])
1468
1578
  say "🔐 Signing: #{sign_style || 'Not Set'}", :cyan
1469
-
1579
+
1470
1580
  # Auto-fetch team ID from API if not provided and project missing it
1471
1581
  team_id_to_use = options[:team]
1472
1582
  project_team_id = parser.team_id(target_name, options[:configuration])
1473
-
1583
+
1474
1584
  if !team_id_to_use && (project_team_id.nil? || project_team_id.empty?)
1475
- say "🔍 No team set in project, fetching from My Signer...", :yellow
1476
-
1585
+ say '🔍 No team set in project, fetching from My Signer...', :yellow
1586
+
1477
1587
  begin
1478
1588
  org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
1479
- api_team_id = org_response.dig(:data, 'app_store_connect_team_id') || org_response['app_store_connect_team_id']
1480
-
1589
+ api_team_id = org_response.dig(:data,
1590
+ 'app_store_connect_team_id') || org_response['app_store_connect_team_id']
1591
+
1481
1592
  if api_team_id && !api_team_id.empty?
1482
1593
  team_id_to_use = api_team_id
1483
1594
  say "✓ Using team from My Signer: #{api_team_id}", :green
1484
1595
  else
1485
- say "⚠️ No team ID configured in My Signer", :yellow
1596
+ say '⚠️ No team ID configured in My Signer', :yellow
1486
1597
  end
1487
- rescue => e
1598
+ rescue StandardError => e
1488
1599
  say "⚠️ Failed to fetch team from API: #{e.message}", :yellow
1489
1600
  end
1490
1601
  end
1491
-
1492
- say ""
1602
+
1603
+ say ''
1493
1604
 
1494
1605
  # Handle signing based on style
1495
1606
  if sign_style == 'Automatic'
1496
- say "ℹ️ Using Automatic signing (Xcode will manage profiles)", :yellow
1497
- say ""
1607
+ say 'ℹ️ Using Automatic signing (Xcode will manage profiles)', :yellow
1498
1608
  elsif sign_style == 'Manual'
1499
1609
  # Check if manual signing is already configured
1500
1610
  if parser.signing_configured?(target_name, options[:configuration])
1501
- say "ℹ️ Manual signing already configured, using existing settings", :yellow
1502
- say ""
1611
+ say 'ℹ️ Manual signing already configured, using existing settings', :yellow
1503
1612
  else
1504
- say "⚠️ Manual signing enabled but not configured", :yellow
1505
- say "🔐 Configuring manual signing via My Signer API...", :cyan
1506
-
1613
+ say '⚠️ Manual signing enabled but not configured', :yellow
1614
+ say '🔐 Configuring manual signing via My Signer API...', :cyan
1615
+
1507
1616
  configurator = Build::Configurator.new(parser, client, config.current_organization_id)
1508
1617
  build_type = options[:type].to_sym
1509
-
1618
+
1510
1619
  profile = configurator.configure!(target_name, options[:configuration], build_type: build_type)
1511
-
1620
+
1512
1621
  say "✓ Configured with profile: #{profile['name']}", :green
1513
- say ""
1514
1622
  end
1515
1623
  else
1516
1624
  # No signing style set, default to automatic signing for simplicity
1517
- say "ℹ️ No signing style set, defaulting to Automatic signing", :yellow
1518
- say "ℹ️ Xcode will manage profiles automatically", :yellow
1519
- say ""
1520
- say "💡 To use Manual signing instead, run: mysigner signing configure", :cyan
1521
- say ""
1625
+ say 'ℹ️ No signing style set, defaulting to Automatic signing', :yellow
1626
+ say 'ℹ️ Xcode will manage profiles automatically', :yellow
1627
+ say ''
1628
+ say '💡 To use Manual signing instead, run: mysigner signing configure', :cyan
1522
1629
  end
1630
+ say ''
1523
1631
 
1524
1632
  # Pre-build validation
1525
- say "🔍 Validating signing setup...", :cyan
1633
+ say '🔍 Validating signing setup...', :cyan
1526
1634
  validator = Signing::Validator.new(parser, target_name, options[:configuration], team_id: team_id_to_use)
1527
1635
  validator.validate!
1528
1636
 
@@ -1538,81 +1646,79 @@ module Mysigner
1538
1646
  skip_extensions: options[:skip_extensions]
1539
1647
  )
1540
1648
 
1541
- say ""
1542
- say "=" * 80, :green
1543
- say "✓ Build succeeded!", :green
1544
- say "=" * 80, :green
1545
- say ""
1649
+ say ''
1650
+ say '=' * 80, :green
1651
+ say '✓ Build succeeded!', :green
1652
+ say '=' * 80, :green
1653
+ say ''
1546
1654
  say "📦 Archive: #{archive_path}", :cyan
1547
- say ""
1548
- say "Next steps:", :bold
1655
+ say ''
1656
+ say 'Next steps:', :bold
1549
1657
  say " mysigner export #{archive_path}"
1550
- say " mysigner ship testflight"
1551
- say ""
1552
-
1658
+ say ' mysigner ship testflight'
1659
+ say ''
1553
1660
  rescue Build::Detector::NoProjectError => e
1554
1661
  error e.message
1555
- say ""
1556
- say "Supported project types:", :yellow
1557
- say " - Native iOS (.xcodeproj, .xcworkspace)"
1558
- say " - Capacitor/Ionic (ionic project with ios/ folder)"
1559
- say " - React Native (RN project with ios/ folder)"
1560
- say " - Flutter (flutter project with ios/ folder)"
1662
+ say ''
1663
+ say 'Supported project types:', :yellow
1664
+ say ' - Native iOS (.xcodeproj, .xcworkspace)'
1665
+ say ' - Capacitor/Ionic (ionic project with ios/ folder)'
1666
+ say ' - React Native (RN project with ios/ folder)'
1667
+ say ' - Flutter (flutter project with ios/ folder)'
1561
1668
  exit 1
1562
1669
  rescue Build::Configurator::ProfileNotFoundError => e
1563
1670
  error e.message
1564
- say ""
1565
- say "Try:", :yellow
1566
- say " mysigner profiles # List available profiles"
1567
- say " mysigner profile create # Create a new profile"
1671
+ say ''
1672
+ say 'Try:', :yellow
1673
+ say ' mysigner profiles # List available profiles'
1674
+ say ' mysigner doctor # Auto-create or repair profiles'
1568
1675
  exit 1
1569
1676
  rescue Build::Executor::BuildError => e
1570
1677
  # Analyze build errors and show helpful suggestions
1571
- say ""
1678
+ say ''
1572
1679
 
1573
- if executor && executor.respond_to?(:build_errors)
1680
+ if executor.respond_to?(:build_errors)
1574
1681
  require_relative '../build/error_analyzer'
1575
1682
  analyzer = Build::ErrorAnalyzer.new(executor.build_errors)
1576
1683
 
1577
- if analyzer.any_issues?
1578
- say analyzer.format_suggestions, :cyan
1579
- end
1684
+ say analyzer.format_suggestions, :cyan if analyzer.any_issues?
1580
1685
  end
1581
1686
 
1582
1687
  error e.message
1583
1688
  exit 1
1584
- rescue => e
1689
+ rescue StandardError => e
1585
1690
  error "Build failed: #{e.message}"
1586
- say ""
1587
- say "Full error:", :yellow
1691
+ say ''
1692
+ say 'Full error:', :yellow
1588
1693
  say e.full_message
1589
1694
  exit 1
1590
1695
  end
1591
1696
  end
1592
1697
 
1593
- desc "export ARCHIVE_PATH", "Export .xcarchive to .ipa (advanced - most users should use 'ship')"
1594
- method_option :method, type: :string, default: 'appstore', desc: 'Export method (appstore, adhoc, enterprise, development)'
1698
+ desc 'export ARCHIVE_PATH', "Export .xcarchive to .ipa (advanced - most users should use 'ship')"
1699
+ method_option :method, type: :string, default: 'appstore',
1700
+ desc: 'Export method (appstore, adhoc, enterprise, development)'
1595
1701
  method_option :output, type: :string, desc: 'Output directory for .ipa file'
1596
1702
  def export(archive_path)
1597
- config = load_config
1598
-
1703
+ load_config
1704
+
1599
1705
  begin
1600
- say "📦 My Signer - Export", :cyan
1601
- say "=" * 80, :cyan
1602
- say ""
1603
-
1706
+ say '📦 My Signer - Export', :cyan
1707
+ say '=' * 80, :cyan
1708
+ say ''
1709
+
1604
1710
  # Validate archive path
1605
1711
  unless File.exist?(archive_path)
1606
1712
  say "✗ Error: Archive not found: #{archive_path}", :red
1607
1713
  exit 1
1608
1714
  end
1609
-
1715
+
1610
1716
  # Create exporter
1611
1717
  exporter = Export::Exporter.new(
1612
1718
  archive_path,
1613
1719
  output_dir: options[:output]
1614
1720
  )
1615
-
1721
+
1616
1722
  # Export
1617
1723
  method = options[:method].to_sym
1618
1724
  ipa_path = exporter.export!(
@@ -1620,130 +1726,180 @@ module Mysigner
1620
1726
  team_id: nil,
1621
1727
  signing_style: 'automatic'
1622
1728
  )
1623
-
1624
- say ""
1625
- say "=" * 80, :green
1626
- say "✓ Export succeeded!", :green
1627
- say "=" * 80, :green
1628
- say ""
1729
+
1730
+ say ''
1731
+ say '=' * 80, :green
1732
+ say '✓ Export succeeded!', :green
1733
+ say '=' * 80, :green
1734
+ say ''
1629
1735
  say "📦 IPA: #{ipa_path}", :green
1630
- say ""
1631
- say "Next steps:", :cyan
1736
+ say ''
1737
+ say 'Next steps:', :cyan
1632
1738
  say " mysigner upload testflight #{ipa_path}", :cyan
1633
- say " mysigner ship testflight", :cyan
1634
- say ""
1635
-
1739
+ say ' mysigner ship testflight', :cyan
1740
+ say ''
1636
1741
  rescue Export::Exporter::ExportError => e
1637
- say ""
1742
+ say ''
1638
1743
  say "✗ Error: #{e.message}", :red
1639
1744
  exit 1
1640
1745
  rescue StandardError => e
1641
- say ""
1746
+ say ''
1642
1747
  say "✗ Unexpected error: #{e.message}", :red
1643
1748
  say e.backtrace.first(5).join("\n"), :red if ENV['DEBUG']
1644
1749
  exit 1
1645
1750
  end
1646
1751
  end
1647
1752
 
1648
- desc "upload testflight IPA_PATH", "Upload existing .ipa to TestFlight (advanced - most users should use 'ship')"
1753
+ desc 'upload testflight IPA_PATH',
1754
+ "Upload existing .ipa to TestFlight (advanced - most users should use 'ship')"
1649
1755
  method_option :wait, type: :boolean, default: false, desc: 'Wait for processing to complete'
1650
1756
  def upload(target, ipa_path)
1651
1757
  unless target == 'testflight'
1652
1758
  error "Only 'testflight' target is supported currently"
1653
- say "Usage: mysigner upload testflight IPA_PATH", :yellow
1759
+ say 'Usage: mysigner upload testflight IPA_PATH', :yellow
1654
1760
  exit 1
1655
1761
  end
1656
1762
 
1657
1763
  config = load_config
1658
1764
  client = create_client(config)
1659
-
1765
+
1660
1766
  begin
1661
- say "☁️ My Signer - Upload to TestFlight", :cyan
1662
- say "=" * 80, :cyan
1663
- say ""
1664
-
1767
+ say '☁️ My Signer - Upload to TestFlight', :cyan
1768
+ say '=' * 80, :cyan
1769
+ say ''
1770
+
1665
1771
  # Validate IPA path
1666
1772
  unless File.exist?(ipa_path)
1667
1773
  say "✗ Error: IPA file not found: #{ipa_path}", :red
1668
1774
  exit 1
1669
1775
  end
1670
-
1671
- # Fetch App Store Connect credentials from API
1672
- say "🔐 Fetching App Store Connect credentials...", :yellow
1673
-
1674
- begin
1675
- org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
1676
- org_data = org_response[:data]
1677
-
1678
- # Check if credentials are configured
1679
- unless org_data['app_store_connect_configured']
1680
- say ""
1681
- say "✗ App Store Connect credentials not configured", :red
1682
- say ""
1683
- say "Quick fix:", :cyan
1684
- say " mysigner doctor # Auto-configure now", :green
1685
- say ""
1686
- say "Or manually:", :cyan
1687
- say " 1. Run: mysigner onboard"
1688
- say " 2. Follow Step 5 to add credentials"
1689
- say ""
1776
+
1777
+ if ENV['MYSIGNER_USE_LEGACY_ASC'] == '1'
1778
+ # LEGACY PATH altool with server-fetched .p8. Will be removed in next release.
1779
+ say '⚠️ MYSIGNER_USE_LEGACY_ASC=1 — using legacy altool upload path (deprecated).', :yellow
1780
+ say '🔐 Fetching App Store Connect credentials...', :yellow
1781
+
1782
+ begin
1783
+ org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
1784
+ org_data = org_response[:data]
1785
+
1786
+ unless org_data['app_store_connect_configured']
1787
+ say ''
1788
+ say '✗ App Store Connect credentials not configured', :red
1789
+ say ''
1790
+ say 'Quick fix:', :cyan
1791
+ say ' mysigner doctor # Auto-configure now', :green
1792
+ say ''
1793
+ say 'Or manually:', :cyan
1794
+ say ' 1. Run: mysigner onboard'
1795
+ say ' 2. Follow Step 5 to add credentials'
1796
+ say ''
1797
+ exit 1
1798
+ end
1799
+
1800
+ api_key = org_data['app_store_connect_key_id']
1801
+ api_issuer = org_data['app_store_connect_issuer_id']
1802
+ private_key = org_data['app_store_connect_private_key']
1803
+
1804
+ unless api_key && api_issuer && private_key
1805
+ say '✗ Error: Invalid credentials received from API', :red
1806
+ exit 1
1807
+ end
1808
+
1809
+ say '✓ Credentials loaded', :green
1810
+ say ''
1811
+ rescue Mysigner::ClientError => e
1812
+ say ''
1813
+ say "✗ Error fetching credentials: #{e.message}", :red
1690
1814
  exit 1
1691
1815
  end
1692
-
1693
- # Get credentials (API will return the decrypted values)
1694
- api_key = org_data['app_store_connect_key_id']
1695
- api_issuer = org_data['app_store_connect_issuer_id']
1696
- private_key = org_data['app_store_connect_private_key']
1697
-
1698
- unless api_key && api_issuer && private_key
1699
- say "✗ Error: Invalid credentials received from API", :red
1816
+
1817
+ uploader = Upload::Uploader.new(
1818
+ ipa_path,
1819
+ api_key: api_key,
1820
+ api_issuer: api_issuer,
1821
+ private_key: private_key
1822
+ )
1823
+
1824
+ uploader.upload!(wait_for_processing: options[:wait])
1825
+ else
1826
+ # DEFAULT PATH — ASC REST Build Upload API.
1827
+ require_relative '../upload/asc_rest_uploader'
1828
+
1829
+ ipa_info = Upload::Uploader.extract_ipa_info(ipa_path)
1830
+ bundle_id = ipa_info[:bundle_id]
1831
+ cf_version = ipa_info[:cf_bundle_version] || '1'
1832
+ cf_short = ipa_info[:cf_bundle_short_version_string] || '1.0'
1833
+
1834
+ if bundle_id.nil? || bundle_id.empty?
1835
+ say '✗ Could not extract bundle identifier from IPA.', :red
1836
+ say ' Ensure the file is a valid iOS .ipa with a Payload/*.app/Info.plist.', :yellow
1700
1837
  exit 1
1701
1838
  end
1702
-
1703
- say "✓ Credentials loaded", :green
1704
- say ""
1705
- rescue Mysigner::ClientError => e
1706
- say ""
1707
- say "✗ Error fetching credentials: #{e.message}", :red
1708
- exit 1
1839
+
1840
+ app_response = client.get("/api/v1/organizations/#{config.current_organization_id}/apple_apps",
1841
+ params: { bundle_id: bundle_id })
1842
+ app = Array(app_response.dig(:data, 'data', 'apps')).first
1843
+
1844
+ unless app && app['id']
1845
+ say "✗ App with bundle ID '#{bundle_id}' not found in MySigner.", :red
1846
+ say ' Run: mysigner sync ios', :yellow
1847
+ exit 1
1848
+ end
1849
+
1850
+ say "📤 Uploading #{File.basename(ipa_path)} via App Store Connect REST API (version #{cf_short} build #{cf_version})...", :cyan
1851
+
1852
+ rest = Mysigner::Upload::AscRestUploader.new(
1853
+ client: client,
1854
+ organization_id: config.current_organization_id,
1855
+ ipa_path: ipa_path,
1856
+ apple_app_id: app['id'],
1857
+ cf_bundle_version: cf_version,
1858
+ cf_bundle_short_version_string: cf_short,
1859
+ platform: 'IOS'
1860
+ )
1861
+
1862
+ result = rest.call
1863
+ case result[:final_state]
1864
+ when 'COMPLETE'
1865
+ say '✓ Upload complete — Apple accepted the build', :green
1866
+ when 'FAILED', 'INVALIDATED'
1867
+ say "✗ Apple rejected the upload: #{result[:final_state]}", :red
1868
+ exit 1
1869
+ when 'TIMEOUT'
1870
+ say '⚠ Apple is still processing — check App Store Connect.', :yellow
1871
+ end
1709
1872
  end
1710
-
1711
- # Create uploader
1712
- uploader = Upload::Uploader.new(
1713
- ipa_path,
1714
- api_key: api_key,
1715
- api_issuer: api_issuer,
1716
- private_key: private_key
1717
- )
1718
-
1719
- # Upload
1720
- result = uploader.upload!(wait_for_processing: options[:wait])
1721
-
1722
- say "🎉 Upload complete!", :green
1723
- say ""
1724
- say "Next steps:", :cyan
1725
- say " • Open App Store Connect to see your build"
1726
- say " • Wait for processing (5-15 minutes)"
1727
- say " • Distribute to TestFlight testers"
1728
- say ""
1729
-
1873
+
1874
+ say '🎉 Upload complete!', :green
1875
+ say ''
1876
+ say 'Next steps:', :cyan
1877
+ say ' • Open App Store Connect to see your build'
1878
+ say ' • Wait for processing (5-15 minutes)'
1879
+ say ' • Distribute to TestFlight testers'
1880
+ say ''
1730
1881
  rescue Upload::Uploader::TransporterNotFoundError => e
1731
- say ""
1882
+ say ''
1732
1883
  say "✗ Error: #{e.message}", :red
1733
1884
  exit 1
1734
1885
  rescue Upload::Uploader::UploadError => e
1735
- say ""
1886
+ say ''
1736
1887
  say "✗ Upload Error: #{e.message}", :red
1737
1888
  exit 1
1889
+ rescue Mysigner::Upload::AscRestUploader::BuildVersionConflictError => e
1890
+ say ''
1891
+ say "✗ #{e.message}", :red
1892
+ say ''
1893
+ exit 1
1738
1894
  rescue StandardError => e
1739
- say ""
1895
+ say ''
1740
1896
  say "✗ Unexpected error: #{e.message}", :red
1741
1897
  say e.backtrace.first(5).join("\n"), :red if ENV['DEBUG']
1742
1898
  exit 1
1743
1899
  end
1744
1900
  end
1745
-
1746
- desc "submit [TRACK]", "📤 Submit existing build for store review (no upload)"
1901
+
1902
+ desc 'submit [TRACK]', '📤 Submit existing build for store review (no upload)'
1747
1903
  long_desc <<~DESC
1748
1904
  Submit an existing build for review without building/uploading.
1749
1905
 
@@ -1782,29 +1938,31 @@ module Mysigner
1782
1938
  method_option :support_url, type: :string, banner: 'URL', desc: 'Support URL (required for submission)'
1783
1939
  method_option :marketing_url, type: :string, banner: 'URL', desc: 'Marketing URL (optional)'
1784
1940
  method_option :privacy_url, type: :string, banner: 'URL', desc: 'Privacy Policy URL (optional)'
1785
- method_option :release_type, type: :string, enum: %w[AFTER_APPROVAL MANUAL SCHEDULED],
1786
- desc: 'Release type: AFTER_APPROVAL (auto-release), MANUAL (hold for manual release), or SCHEDULED'
1787
- method_option :scheduled_date, type: :string, banner: 'ISO8601',
1788
- desc: 'Scheduled release date (ISO 8601 format, e.g., 2026-02-01T10:00:00Z). Required when --release-type=SCHEDULED'
1941
+ method_option :release_type, type: :string, enum: %w[AFTER_APPROVAL MANUAL SCHEDULED],
1942
+ desc: 'Release type: AFTER_APPROVAL (auto-release), MANUAL (hold for manual release), or SCHEDULED'
1943
+ method_option :scheduled_date, type: :string, banner: 'ISO8601',
1944
+ desc: 'Scheduled release date (ISO 8601 format, e.g., 2026-02-01T10:00:00Z). Required when --release-type=SCHEDULED'
1789
1945
  method_option :platform, type: :string, desc: 'Platform: ios or android'
1790
1946
  method_option :package_name, type: :string, desc: 'Android package name'
1791
1947
  method_option :version_code, type: :string, desc: 'Android version code to promote'
1792
1948
  method_option :release_notes, type: :string, desc: 'Release notes for Android'
1949
+ method_option :auto_submit, type: :boolean,
1950
+ desc: 'Submit for review. Defaults to dashboard CLI Defaults, else true. Use --no-auto-submit to skip.'
1793
1951
  def submit(track = nil)
1794
1952
  config = load_config
1795
1953
  client = create_client(config)
1796
1954
 
1797
1955
  # Determine platform
1798
- android_tracks = ['internal', 'alpha', 'beta', 'production']
1956
+ android_tracks = %w[internal alpha beta production]
1799
1957
  platform = options[:platform]&.to_sym
1800
1958
 
1801
1959
  # Auto-detect platform from track argument or option
1802
1960
  if platform.nil?
1803
- if track && android_tracks.include?(track)
1804
- platform = :android
1805
- else
1806
- platform = :ios
1807
- end
1961
+ platform = if track && android_tracks.include?(track)
1962
+ :android
1963
+ else
1964
+ :ios
1965
+ end
1808
1966
  end
1809
1967
 
1810
1968
  # Route to Android submit if needed
@@ -1814,13 +1972,13 @@ module Mysigner
1814
1972
  end
1815
1973
 
1816
1974
  # iOS submit flow continues below...
1817
- say "📤 Submit for App Store Review", :cyan
1818
- say "=" * 80, :cyan
1819
- say ""
1820
-
1975
+ say '📤 Submit for App Store Review', :cyan
1976
+ say '=' * 80, :cyan
1977
+ say ''
1978
+
1821
1979
  # Get bundle ID from project or option
1822
1980
  bundle_id = options[:bundle_id]
1823
-
1981
+
1824
1982
  unless bundle_id
1825
1983
  begin
1826
1984
  project_info = Build::Detector.detect
@@ -1828,32 +1986,36 @@ module Mysigner
1828
1986
  target_name = parser.main_target.name
1829
1987
  bundle_id = parser.bundle_id(target_name, 'Release')
1830
1988
  say "✓ Detected bundle ID from project: #{bundle_id}", :green
1831
- rescue
1832
- error "Could not detect bundle ID from project"
1833
- say ""
1834
- say "Please specify manually:", :yellow
1835
- say " mysigner submit --bundle-id com.your.app.id", :cyan
1989
+ rescue StandardError
1990
+ error 'Could not detect bundle ID from project'
1991
+ say ''
1992
+ say 'Please specify manually:', :yellow
1993
+ say ' mysigner submit --bundle-id com.your.app.id', :cyan
1836
1994
  exit 1
1837
1995
  end
1838
1996
  end
1839
-
1840
- say ""
1997
+
1998
+ say ''
1841
1999
  say "📱 Bundle ID: #{bundle_id}", :cyan
1842
- say ""
1843
-
2000
+ say ''
2001
+
1844
2002
  begin
1845
2003
  require_relative '../upload/app_store_submission'
1846
2004
  require_relative '../upload/app_store_automation'
1847
-
2005
+
1848
2006
  automation = Upload::AppStoreAutomation.new(
1849
2007
  client: client,
1850
2008
  organization_id: config.current_organization_id,
1851
2009
  opts: {
1852
- wait: false, # No need to wait - only using already-processed builds
1853
- no_submit: false
2010
+ wait: false, # No need to wait - only using already-processed builds
2011
+ no_submit: false,
2012
+ # `mysigner submit` without --auto-submit/--no-auto-submit
2013
+ # defaults to submitting; cli_defaults.auto_submit=false can
2014
+ # still suppress it.
2015
+ default_submit: true
1854
2016
  }
1855
2017
  )
1856
-
2018
+
1857
2019
  # Get version from project or option
1858
2020
  version_string = options[:version]
1859
2021
  unless version_string
@@ -1862,42 +2024,49 @@ module Mysigner
1862
2024
  parser ||= Build::Parser.new(project_info)
1863
2025
  target_name ||= parser.main_target.name
1864
2026
  version_string = parser.build_settings(target_name, 'Release')['MARKETING_VERSION']
1865
- rescue
2027
+ rescue StandardError
1866
2028
  version_string = nil
1867
2029
  end
1868
2030
  end
1869
-
2031
+
1870
2032
  build_info = {
1871
2033
  bundle_id: bundle_id,
1872
2034
  version: version_string || '1.0',
1873
2035
  build_number: options[:build_number]
1874
2036
  }
1875
-
1876
- # Force submission when running 'mysigner submit' explicitly
1877
- # Build metadata overrides from CLI options
1878
- metadata_overrides = { 'auto_submit' => true }
1879
- override_keys = ['auto_submit']
1880
-
2037
+
2038
+ # `mysigner submit` defaults to submitting (see AppStoreAutomation
2039
+ # opts[:default_submit]=true above) but no longer hard-clobbers
2040
+ # the user's dashboard `cli_defaults.auto_submit = false`.
2041
+ # Precedence: --auto-submit flag > cli_defaults > command default.
2042
+ metadata_overrides = {}
2043
+ override_keys = []
2044
+
2045
+ if options.key?(:auto_submit)
2046
+ metadata_overrides['auto_submit'] = options[:auto_submit]
2047
+ override_keys << 'auto_submit'
2048
+ end
2049
+
1881
2050
  if options[:whats_new]
1882
2051
  metadata_overrides['whats_new'] = options[:whats_new]
1883
2052
  override_keys << 'whats_new'
1884
2053
  end
1885
-
2054
+
1886
2055
  if options[:support_url]
1887
2056
  metadata_overrides['support_url'] = options[:support_url]
1888
2057
  override_keys << 'support_url'
1889
2058
  end
1890
-
2059
+
1891
2060
  if options[:marketing_url]
1892
2061
  metadata_overrides['marketing_url'] = options[:marketing_url]
1893
2062
  override_keys << 'marketing_url'
1894
2063
  end
1895
-
2064
+
1896
2065
  if options[:privacy_url]
1897
2066
  metadata_overrides['privacy_policy_url'] = options[:privacy_url]
1898
2067
  override_keys << 'privacy_policy_url'
1899
2068
  end
1900
-
2069
+
1901
2070
  if options[:release_type]
1902
2071
  # Validate release_type
1903
2072
  valid_types = %w[AFTER_APPROVAL MANUAL SCHEDULED]
@@ -1909,25 +2078,25 @@ module Mysigner
1909
2078
  end
1910
2079
  metadata_overrides['release_type'] = rt
1911
2080
  override_keys << 'release_type'
1912
-
2081
+
1913
2082
  # Validate scheduled_date is provided when SCHEDULED
1914
2083
  if rt == 'SCHEDULED' && !options[:scheduled_date]
1915
- error "Scheduled release date is required when --release-type=SCHEDULED"
1916
- say "Use: --scheduled-date 2026-02-01T10:00:00Z", :yellow
2084
+ error 'Scheduled release date is required when --release-type=SCHEDULED'
2085
+ say 'Use: --scheduled-date 2026-02-01T10:00:00Z', :yellow
1917
2086
  exit 1
1918
2087
  end
1919
2088
  end
1920
-
2089
+
1921
2090
  if options[:scheduled_date]
1922
2091
  begin
1923
2092
  parsed_date = Time.parse(options[:scheduled_date])
1924
- if parsed_date < Time.now + 3600 # At least 1 hour in the future
1925
- error "Scheduled date must be at least 1 hour in the future"
2093
+ if parsed_date < Time.now + 3600 # At least 1 hour in the future
2094
+ error 'Scheduled date must be at least 1 hour in the future'
1926
2095
  exit 1
1927
2096
  end
1928
2097
  metadata_overrides['earliest_release_date'] = parsed_date.utc.iso8601
1929
2098
  override_keys << 'earliest_release_date'
1930
-
2099
+
1931
2100
  # Auto-set release_type to SCHEDULED if not already set
1932
2101
  unless metadata_overrides['release_type']
1933
2102
  metadata_overrides['release_type'] = 'SCHEDULED'
@@ -1935,11 +2104,11 @@ module Mysigner
1935
2104
  end
1936
2105
  rescue ArgumentError => e
1937
2106
  error "Invalid date format: #{options[:scheduled_date]}"
1938
- say "Use ISO 8601 format, e.g., 2026-02-01T10:00:00Z", :yellow
2107
+ say 'Use ISO 8601 format, e.g., 2026-02-01T10:00:00Z', :yellow
1939
2108
  exit 1
1940
2109
  end
1941
2110
  end
1942
-
2111
+
1943
2112
  submission = Upload::AppStoreSubmission.new(
1944
2113
  client,
1945
2114
  config.current_organization_id,
@@ -1947,71 +2116,70 @@ module Mysigner
1947
2116
  metadata_overrides: metadata_overrides,
1948
2117
  override_sources: [{ type: :inline, keys: override_keys }]
1949
2118
  )
1950
-
2119
+
1951
2120
  result = submission.submit_for_review!(automation: automation)
1952
-
1953
- say ""
1954
- say "=" * 80, :green
1955
- say "✓ Submission Complete!", :green
1956
- say "=" * 80, :green
1957
- say ""
1958
-
2121
+
2122
+ say ''
2123
+ say '=' * 80, :green
2124
+ say '✓ Submission Complete!', :green
2125
+ say '=' * 80, :green
2126
+ say ''
2127
+
1959
2128
  if result[:automation][:submitted]
1960
- say "🎉 Your app is submitted for App Store review!", :green
1961
- say ""
1962
- say "Monitor status:", :cyan
1963
- say " https://appstoreconnect.apple.com/apps", :green
2129
+ say '🎉 Your app is submitted for App Store review!', :green
2130
+ say ''
2131
+ say 'Monitor status:', :cyan
2132
+ say ' https://appstoreconnect.apple.com/apps', :green
1964
2133
  else
1965
2134
  say "⚠️ Submission skipped: #{result[:automation][:skip_reason]}", :yellow
1966
2135
  end
1967
- say ""
1968
-
2136
+ say ''
1969
2137
  rescue Upload::AppStoreAutomation::AutomationError => e
1970
2138
  # Use enhanced error handler for App Store automation errors
1971
2139
  handle_apple_api_error(e, context: {
1972
- title: 'Submission Failed',
1973
- bundle_id: options[:bundle_id]
1974
- })
2140
+ title: 'Submission Failed',
2141
+ bundle_id: options[:bundle_id]
2142
+ })
1975
2143
  exit 1
1976
2144
  rescue Mysigner::ClientError => e
1977
2145
  # Handle API client errors with actionable suggestions
1978
2146
  handle_apple_api_error(e, context: {
1979
- title: 'API Error'
1980
- })
2147
+ title: 'API Error'
2148
+ })
1981
2149
  exit 1
1982
- rescue => e
1983
- say ""
1984
- say "=" * 80, :red
1985
- say "✗ Submission Failed", :red
1986
- say "=" * 80, :red
1987
- say ""
2150
+ rescue StandardError => e
2151
+ say ''
2152
+ say '=' * 80, :red
2153
+ say '✗ Submission Failed', :red
2154
+ say '=' * 80, :red
2155
+ say ''
1988
2156
  say "Error: #{e.message}", :red
1989
- say ""
1990
-
2157
+ say ''
2158
+
1991
2159
  # Try to show actionable suggestions for unknown errors
1992
2160
  show_actionable_suggestions(e.message, platform: :ios)
1993
-
2161
+
1994
2162
  show_debug_info(e) if ENV['DEBUG']
1995
2163
  exit 1
1996
2164
  end
1997
2165
  end
1998
2166
 
1999
- desc "signing configure", "🧙 Wizard: Configure manual code signing in your Xcode project"
2167
+ desc 'signing configure', '🧙 Wizard: Configure manual code signing in your Xcode project'
2000
2168
  long_desc <<~DESC
2001
2169
  Guides you through setting up manual code signing for your project:
2002
-
2170
+
2003
2171
  1. Detects your project and targets
2004
2172
  2. Shows current signing configuration
2005
2173
  3. Helps you select team ID and provisioning profile
2006
2174
  4. Applies configuration to your Xcode project
2007
2175
  5. Validates the setup
2008
-
2176
+
2009
2177
  This is useful when automatic signing doesn't work or you need specific profiles.
2010
-
2178
+
2011
2179
  OPTIONS:
2012
2180
  --target NAME Configure specific target only
2013
2181
  --all-targets Configure all app and extension targets
2014
-
2182
+
2015
2183
  EXAMPLES:
2016
2184
  mysigner signing configure # Configure main app (auto-detect)
2017
2185
  mysigner signing configure --target MyWidget # Configure specific target
@@ -2022,50 +2190,50 @@ module Mysigner
2022
2190
  def signing(action)
2023
2191
  unless action == 'configure'
2024
2192
  error "Unknown action: #{action}"
2025
- say "Usage: mysigner signing configure", :yellow
2193
+ say 'Usage: mysigner signing configure', :yellow
2026
2194
  exit 1
2027
2195
  end
2028
-
2196
+
2029
2197
  config = load_config
2030
-
2198
+
2031
2199
  unless config.api_token
2032
2200
  error "Not logged in. Please run 'mysigner login' or 'mysigner onboard' first."
2033
2201
  exit 1
2034
2202
  end
2035
-
2203
+
2036
2204
  client = create_client(config)
2037
-
2205
+
2038
2206
  begin
2039
2207
  # Detect project
2040
2208
  project_info = Build::Detector.detect
2041
2209
  parser = Build::Parser.new(project_info)
2042
-
2210
+
2043
2211
  # Validate options
2044
2212
  if options[:target] && options[:all_targets]
2045
- error "Cannot use both --target and --all-targets"
2213
+ error 'Cannot use both --target and --all-targets'
2046
2214
  exit 1
2047
2215
  end
2048
-
2216
+
2049
2217
  # Check current signing style
2050
2218
  target_name = options[:target] || parser.main_target.name
2051
2219
  signing_style = parser.code_sign_style(target_name)
2052
-
2220
+
2053
2221
  if signing_style == 'Automatic'
2054
- say "⚠️ Project uses Automatic signing", :yellow
2055
- say ""
2056
- say "Your project is configured for Automatic signing, which means:", :cyan
2057
- say " • Xcode manages profiles automatically"
2058
- say " • No manual profile configuration needed"
2059
- say " • Team ID is all you need"
2060
- say ""
2222
+ say '⚠️ Project uses Automatic signing', :yellow
2223
+ say ''
2224
+ say 'Your project is configured for Automatic signing, which means:', :cyan
2225
+ say ' • Xcode manages profiles automatically'
2226
+ say ' • No manual profile configuration needed'
2227
+ say ' • Team ID is all you need'
2228
+ say ''
2061
2229
  say "Current Team ID: #{parser.team_id(target_name) || '(not set)'}", :green
2062
- say ""
2063
- say "You can build with: mysigner build"
2064
- say ""
2065
- say "💡 To convert to Manual signing, use: mysigner signing configure --force-manual"
2230
+ say ''
2231
+ say 'You can build with: mysigner build'
2232
+ say ''
2233
+ say '💡 To convert to Manual signing, use: mysigner signing configure --force-manual'
2066
2234
  return
2067
2235
  end
2068
-
2236
+
2069
2237
  # Run wizard for Manual signing
2070
2238
  require_relative '../signing/wizard'
2071
2239
  wizard_options = {
@@ -2074,7 +2242,6 @@ module Mysigner
2074
2242
  }
2075
2243
  wizard = Signing::Wizard.new(parser, client, config.current_organization_id, wizard_options)
2076
2244
  wizard.run!
2077
-
2078
2245
  rescue Build::Detector::NoProjectError => e
2079
2246
  error e.message
2080
2247
  exit 1
@@ -2087,7 +2254,6 @@ module Mysigner
2087
2254
  exit 1
2088
2255
  end
2089
2256
  end
2090
-
2091
2257
  end
2092
2258
  end
2093
2259
  end