mysigner 0.1.3 → 0.1.5

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.
@@ -6,6 +6,7 @@ require 'time'
6
6
  require_relative '../upload/play_store_uploader'
7
7
  require_relative '../upload/app_store_automation'
8
8
  require_relative '../upload/app_store_submission'
9
+ require_relative '../upload/asc_rest_uploader'
9
10
 
10
11
  module Mysigner
11
12
  class CLI < Thor
@@ -63,12 +64,15 @@ module Mysigner
63
64
  method_option :bundle_id, aliases: '-b', desc: 'Bundle ID (overrides project setting)'
64
65
  method_option :platform, type: :string, desc: 'Platform: ios or android (auto-detect if not specified)'
65
66
  method_option :package_name, type: :string, desc: 'Android package name (overrides project setting)'
66
- 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)'
67
69
  method_option :version, type: :string, desc: 'Set version name for Android (e.g., 1.2.0)'
68
70
  method_option :release_type, type: :string, enum: %w[AFTER_APPROVAL MANUAL SCHEDULED],
69
71
  desc: 'Release type for App Store: AFTER_APPROVAL, MANUAL, or SCHEDULED'
70
72
  method_option :scheduled_date, type: :string, banner: 'ISO8601',
71
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.'
72
76
  def ship(target)
73
77
  ios_targets = %w[testflight appstore]
74
78
  android_targets = %w[internal alpha beta production]
@@ -302,47 +306,96 @@ module Mysigner
302
306
 
303
307
  upload_start = Time.now
304
308
 
305
- # Fetch App Store Connect credentials
306
- say '🔐 Fetching App Store Connect credentials...', :yellow
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
307
312
 
308
- org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
309
- org_data = org_response[:data]
313
+ # Fetch App Store Connect credentials
314
+ say '🔐 Fetching App Store Connect credentials...', :yellow
310
315
 
311
- unless org_data['app_store_connect_configured']
312
- say ''
313
- say '✗ App Store Connect credentials not configured', :red
314
- say ''
315
- say 'Quick fix:', :cyan
316
- say ' mysigner doctor # Auto-configure now', :green
317
- say ''
318
- say 'Or manually:', :cyan
319
- say ' 1. Run: mysigner onboard'
320
- say ' 2. Follow Step 5 to add credentials'
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
321
343
  say ''
322
- exit 1
323
- end
324
344
 
325
- api_key = org_data['app_store_connect_key_id']
326
- api_issuer = org_data['app_store_connect_issuer_id']
327
- private_key = org_data['app_store_connect_private_key']
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
+ )
328
352
 
329
- unless api_key && api_issuer && private_key
330
- say '✗ Error: Invalid credentials received from API', :red
331
- exit 1
332
- end
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'
333
357
 
334
- say '✓ Credentials loaded', :green
335
- say ''
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
336
364
 
337
- # Upload
338
- uploader = Upload::Uploader.new(
339
- ipa_path,
340
- api_key: api_key,
341
- api_issuer: api_issuer,
342
- private_key: private_key
343
- )
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'
344
375
 
345
- uploader.upload!(wait_for_processing: options[:wait])
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
398
+ end
346
399
 
347
400
  timings[:upload] = Time.now - upload_start
348
401
 
@@ -427,14 +480,26 @@ module Mysigner
427
480
  wait: true,
428
481
  timeout: 1800,
429
482
  poll_interval: 15,
430
- 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
431
488
  }
432
489
  )
433
490
 
434
491
  # Submit the new build (use its specific build number)
435
- # Build metadata overrides from CLI options
436
- ship_overrides = { 'auto_submit' => true }
437
- ship_override_keys = ['auto_submit']
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
438
503
 
439
504
  if options[:release_type]
440
505
  # Validate release_type
@@ -477,6 +542,8 @@ module Mysigner
477
542
  end
478
543
  end
479
544
 
545
+ override_sources << { type: :inline, keys: ship_override_keys }
546
+
480
547
  submission = Upload::AppStoreSubmission.new(
481
548
  client,
482
549
  config.current_organization_id,
@@ -485,7 +552,7 @@ module Mysigner
485
552
  build_number: new_build['build_number'] # Use the specific build we found
486
553
  },
487
554
  metadata_overrides: ship_overrides,
488
- override_sources: [{ type: :inline, keys: ship_override_keys }]
555
+ override_sources: override_sources
489
556
  )
490
557
 
491
558
  submission_result = submission.submit_for_review!(automation: automation)
@@ -611,6 +678,11 @@ module Mysigner
611
678
  ipa_path: ipa_path
612
679
  })
613
680
  exit 1
681
+ rescue Mysigner::Upload::AscRestUploader::BuildVersionConflictError => e
682
+ say ''
683
+ say "✗ #{e.message}", :red
684
+ say ''
685
+ exit 1
614
686
  rescue StandardError => e
615
687
  say ''
616
688
  say '=' * 80, :red
@@ -805,49 +877,82 @@ module Mysigner
805
877
 
806
878
  upload_start = Time.now
807
879
 
808
- # Fetch Google Play credentials from API
809
- say '🔐 Fetching Google Play credentials...', :yellow
810
-
811
- org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
812
- org_data = org_response[:data]
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
813
883
 
814
- unless org_data['google_play_configured']
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
815
890
  say ''
816
891
  say '✗ Google Play credentials not configured', :red
817
892
  say ''
818
893
  say 'Quick fix:', :cyan
819
894
  say ' Configure Google Play credentials in My Signer dashboard', :green
820
895
  say ''
821
- say 'Or configure in My Signer dashboard', :yellow
822
- say ''
823
896
  exit 1
824
897
  end
825
898
 
826
- service_account_json = org_data['google_play_service_account']
827
-
828
- unless service_account_json
829
- 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
830
901
  exit 1
831
902
  end
832
903
 
833
- say '✓ Credentials loaded', :green
904
+ say '✓ Access token minted (valid ~1 hour)', :green
834
905
  say ''
835
906
 
836
- # Upload to Google Play
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
837
917
  require_relative '../upload/play_store_uploader'
838
918
 
919
+ # Merge release notes: flag > defaults.release_notes (Hash)
839
920
  release_notes = nil
840
- release_notes = { 'en-US' => options[:release_notes] } if options[:release_notes]
921
+ if options[:release_notes]
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']
925
+ end
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)
841
940
 
842
941
  uploader = Upload::PlayStoreUploader.new(
843
942
  aab_path: aab_path,
844
- service_account_json: service_account_json,
943
+ access_token: access_token,
845
944
  package_name: package_name
846
945
  )
847
946
 
848
947
  uploader.upload!(
849
- track: track,
850
- release_notes: release_notes
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
851
956
  )
852
957
 
853
958
  timings[:upload] = Time.now - upload_start
@@ -988,6 +1093,29 @@ module Mysigner
988
1093
  end
989
1094
  end
990
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
+
991
1119
  # Fetch highest version code from API
992
1120
  def fetch_android_highest_version_code(client, config, package_name)
993
1121
  response = client.get("/api/v1/organizations/#{config.current_organization_id}/android_apps")
@@ -1091,13 +1219,15 @@ module Mysigner
1091
1219
  say "🎯 Target Track: #{track_label}", :cyan
1092
1220
  say ''
1093
1221
 
1094
- # Fetch Google Play credentials
1095
- say '🔐 Fetching Google Play credentials...', :yellow
1096
-
1097
- org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
1098
- org_data = org_response[:data]
1222
+ # Phase 0: mint short-lived access token server-side; JSON stays on server
1223
+ say '🔐 Requesting Google Play access token...', :yellow
1099
1224
 
1100
- unless org_data['google_play_configured']
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
1101
1231
  say ''
1102
1232
  say '✗ Google Play credentials not configured', :red
1103
1233
  say ''
@@ -1105,8 +1235,12 @@ module Mysigner
1105
1235
  exit 1
1106
1236
  end
1107
1237
 
1108
- service_account_json = org_data['google_play_service_account']
1109
- say ' Credentials loaded', :green
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
1110
1244
  say ''
1111
1245
 
1112
1246
  # Get the latest build from the API
@@ -1147,19 +1281,11 @@ module Mysigner
1147
1281
  release_notes = nil
1148
1282
  release_notes = { 'en-US' => options[:release_notes] } if options[:release_notes]
1149
1283
 
1150
- # Create a minimal uploader just for track assignment
1151
- # We need to use the Google API directly for this
1152
- require 'googleauth'
1284
+ # Use the Google API directly with the bare bearer token for track assignment
1153
1285
  require 'google/apis/androidpublisher_v3'
1154
- require 'stringio'
1155
-
1156
- auth = Google::Auth::ServiceAccountCredentials.make_creds(
1157
- json_key_io: StringIO.new(service_account_json),
1158
- scope: 'https://www.googleapis.com/auth/androidpublisher'
1159
- )
1160
1286
 
1161
1287
  service = Google::Apis::AndroidpublisherV3::AndroidPublisherService.new
1162
- service.authorization = auth
1288
+ service.authorization = access_token
1163
1289
 
1164
1290
  # Create edit
1165
1291
  edit = service.insert_edit(package_name, Google::Apis::AndroidpublisherV3::AppEdit.new)
@@ -1648,56 +1774,102 @@ module Mysigner
1648
1774
  exit 1
1649
1775
  end
1650
1776
 
1651
- # Fetch App Store Connect credentials from API
1652
- say '🔐 Fetching App Store Connect credentials...', :yellow
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
1653
1781
 
1654
- begin
1655
- org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
1656
- org_data = org_response[:data]
1782
+ begin
1783
+ org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
1784
+ org_data = org_response[:data]
1657
1785
 
1658
- # Check if credentials are configured
1659
- unless org_data['app_store_connect_configured']
1660
- say ''
1661
- say '✗ App Store Connect credentials not configured', :red
1662
- say ''
1663
- say 'Quick fix:', :cyan
1664
- say ' mysigner doctor # Auto-configure now', :green
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
1665
1810
  say ''
1666
- say 'Or manually:', :cyan
1667
- say ' 1. Run: mysigner onboard'
1668
- say ' 2. Follow Step 5 to add credentials'
1811
+ rescue Mysigner::ClientError => e
1669
1812
  say ''
1813
+ say "✗ Error fetching credentials: #{e.message}", :red
1670
1814
  exit 1
1671
1815
  end
1672
1816
 
1673
- # Get credentials (API will return the decrypted values)
1674
- api_key = org_data['app_store_connect_key_id']
1675
- api_issuer = org_data['app_store_connect_issuer_id']
1676
- private_key = org_data['app_store_connect_private_key']
1817
+ uploader = Upload::Uploader.new(
1818
+ ipa_path,
1819
+ api_key: api_key,
1820
+ api_issuer: api_issuer,
1821
+ private_key: private_key
1822
+ )
1677
1823
 
1678
- unless api_key && api_issuer && private_key
1679
- say '✗ Error: Invalid credentials received from API', :red
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
1680
1837
  exit 1
1681
1838
  end
1682
1839
 
1683
- say '✓ Credentials loaded', :green
1684
- say ''
1685
- rescue Mysigner::ClientError => e
1686
- say ''
1687
- say "✗ Error fetching credentials: #{e.message}", :red
1688
- exit 1
1689
- end
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
1690
1843
 
1691
- # Create uploader
1692
- uploader = Upload::Uploader.new(
1693
- ipa_path,
1694
- api_key: api_key,
1695
- api_issuer: api_issuer,
1696
- private_key: private_key
1697
- )
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
+ )
1698
1861
 
1699
- # Upload
1700
- uploader.upload!(wait_for_processing: options[:wait])
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
1872
+ end
1701
1873
 
1702
1874
  say '🎉 Upload complete!', :green
1703
1875
  say ''
@@ -1714,6 +1886,11 @@ module Mysigner
1714
1886
  say ''
1715
1887
  say "✗ Upload Error: #{e.message}", :red
1716
1888
  exit 1
1889
+ rescue Mysigner::Upload::AscRestUploader::BuildVersionConflictError => e
1890
+ say ''
1891
+ say "✗ #{e.message}", :red
1892
+ say ''
1893
+ exit 1
1717
1894
  rescue StandardError => e
1718
1895
  say ''
1719
1896
  say "✗ Unexpected error: #{e.message}", :red
@@ -1769,6 +1946,8 @@ module Mysigner
1769
1946
  method_option :package_name, type: :string, desc: 'Android package name'
1770
1947
  method_option :version_code, type: :string, desc: 'Android version code to promote'
1771
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.'
1772
1951
  def submit(track = nil)
1773
1952
  config = load_config
1774
1953
  client = create_client(config)
@@ -1829,7 +2008,11 @@ module Mysigner
1829
2008
  organization_id: config.current_organization_id,
1830
2009
  opts: {
1831
2010
  wait: false, # No need to wait - only using already-processed builds
1832
- no_submit: false
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
1833
2016
  }
1834
2017
  )
1835
2018
 
@@ -1852,10 +2035,17 @@ module Mysigner
1852
2035
  build_number: options[:build_number]
1853
2036
  }
1854
2037
 
1855
- # Force submission when running 'mysigner submit' explicitly
1856
- # Build metadata overrides from CLI options
1857
- metadata_overrides = { 'auto_submit' => true }
1858
- override_keys = ['auto_submit']
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
1859
2049
 
1860
2050
  if options[:whats_new]
1861
2051
  metadata_overrides['whats_new'] = options[:whats_new]
@@ -36,6 +36,38 @@ module Mysigner
36
36
  end
37
37
  end
38
38
 
39
+ # Client-side UDID validity check for iOS devices. Matches the two
40
+ # formats Apple uses: 25-character alphanumeric (older devices pre-
41
+ # iPhone X) and 40-character hex, optionally with a single dash after
42
+ # the first 8 chars (newer). Also rejects obviously synthetic values
43
+ # (all zeros, single-character repeats) that Apple's dev-portal
44
+ # sandbox has been known to accept even though they can never match
45
+ # a real device.
46
+ def valid_ios_udid?(udid)
47
+ return false if udid.nil? || udid.strip.empty?
48
+
49
+ normalized = udid.strip.upcase
50
+
51
+ # 25-char legacy UDID: alphanumeric
52
+ legacy = normalized.match?(/\A[0-9A-F]{25}\z/)
53
+
54
+ # 40-char modern UDID: hex, optional dash after first 8 chars
55
+ modern_plain = normalized.match?(/\A[0-9A-F]{40}\z/)
56
+ modern_dashed = normalized.match?(/\A[0-9A-F]{8}-[0-9A-F]{16}\z/) # 8-16 form some tools emit (older spec)
57
+ modern_full_dashed = normalized.match?(/\A[0-9A-F]{8}-[0-9A-F]{32}\z/) # 8-32 (what xcrun outputs)
58
+
59
+ return false unless legacy || modern_plain || modern_dashed || modern_full_dashed
60
+
61
+ hex_only = normalized.delete('-')
62
+ # Reject trivially synthetic UDIDs. A real UDID has at least 4
63
+ # distinct hex characters among its 25/40 positions; "000…" or
64
+ # "AAAA…" or "012345…" style sequences flunk that.
65
+ distinct = hex_only.chars.uniq.size
66
+ return false if distinct < 4
67
+
68
+ true
69
+ end
70
+
39
71
  def load_config
40
72
  # CI/CD mode: prefer environment variables when set
41
73
  return Config.from_env if Config.env_configured?
@@ -634,8 +634,15 @@ module Mysigner
634
634
  end
635
635
 
636
636
  no_commands do
637
- # Helper method for yes/no prompts with Enter defaulting to yes
637
+ # Helper method for yes/no prompts with Enter defaulting to yes.
638
+ # When stdin is not a TTY (pipe, redirect, CI), default to NO so
639
+ # `mysigner doctor` never silently mutates user files (e.g. ~/.zshrc)
640
+ # without an interactive confirmation.
638
641
  def yes_with_default?(statement, color = nil)
642
+ unless $stdin.tty?
643
+ say "#{statement} [Y/n] (non-interactive: assuming no)", color
644
+ return false
645
+ end
639
646
  response = ask("#{statement} [Y/n]", color).to_s.strip.downcase
640
647
  response.empty? || response == 'y' || response == 'yes'
641
648
  end