mysigner 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.DS_Store +0 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +3 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +7 -0
  7. data/CODE_OF_CONDUCT.md +74 -0
  8. data/Gemfile +7 -0
  9. data/Gemfile.lock +137 -0
  10. data/LICENSE +201 -0
  11. data/MANUAL_TEST.md +341 -0
  12. data/README.md +493 -0
  13. data/Rakefile +6 -0
  14. data/bin/console +14 -0
  15. data/bin/setup +8 -0
  16. data/exe/mysigner +5 -0
  17. data/lib/mysigner/build/android_executor.rb +367 -0
  18. data/lib/mysigner/build/android_parser.rb +293 -0
  19. data/lib/mysigner/build/configurator.rb +126 -0
  20. data/lib/mysigner/build/detector.rb +388 -0
  21. data/lib/mysigner/build/error_analyzer.rb +193 -0
  22. data/lib/mysigner/build/executor.rb +176 -0
  23. data/lib/mysigner/build/parser.rb +206 -0
  24. data/lib/mysigner/cli/auth_commands.rb +1381 -0
  25. data/lib/mysigner/cli/build_commands.rb +2095 -0
  26. data/lib/mysigner/cli/concerns/actionable_suggestions.rb +500 -0
  27. data/lib/mysigner/cli/concerns/api_helpers.rb +131 -0
  28. data/lib/mysigner/cli/concerns/error_handlers.rb +446 -0
  29. data/lib/mysigner/cli/concerns/helpers.rb +63 -0
  30. data/lib/mysigner/cli/diagnostic_commands.rb +1034 -0
  31. data/lib/mysigner/cli/resource_commands.rb +2670 -0
  32. data/lib/mysigner/cli.rb +43 -0
  33. data/lib/mysigner/client.rb +189 -0
  34. data/lib/mysigner/config.rb +311 -0
  35. data/lib/mysigner/export/exporter.rb +150 -0
  36. data/lib/mysigner/signing/certificate_checker.rb +148 -0
  37. data/lib/mysigner/signing/keystore_manager.rb +239 -0
  38. data/lib/mysigner/signing/validator.rb +150 -0
  39. data/lib/mysigner/signing/wizard.rb +784 -0
  40. data/lib/mysigner/upload/app_store_automation.rb +402 -0
  41. data/lib/mysigner/upload/app_store_submission.rb +312 -0
  42. data/lib/mysigner/upload/play_store_uploader.rb +378 -0
  43. data/lib/mysigner/upload/uploader.rb +373 -0
  44. data/lib/mysigner/version.rb +3 -0
  45. data/lib/mysigner.rb +15 -0
  46. data/mysigner.gemspec +78 -0
  47. data/test_manual.rb +102 -0
  48. metadata +286 -0
@@ -0,0 +1,784 @@
1
+ require 'faraday'
2
+ require 'json'
3
+
4
+ module Mysigner
5
+ module Signing
6
+ class Wizard
7
+ class WizardError < StandardError; end
8
+
9
+ def initialize(parser, client, organization_id, options = {})
10
+ @parser = parser
11
+ @client = client
12
+ @organization_id = organization_id
13
+ @options = options
14
+ end
15
+
16
+ # Run the interactive wizard
17
+ def run!
18
+ puts ""
19
+ puts "🧙 Manual Signing Setup Wizard"
20
+ puts "=" * 80
21
+ puts ""
22
+
23
+ # Check if we're configuring all targets
24
+ if @options[:all_targets]
25
+ configure_all_targets
26
+ else
27
+ configure_single_target(@options[:target])
28
+ end
29
+ end
30
+
31
+ def configure_all_targets
32
+ targets = @parser.signable_targets
33
+
34
+ if targets.empty?
35
+ error "No signable targets found in project"
36
+ return
37
+ end
38
+
39
+ puts "Found #{targets.count} signable target(s):"
40
+ targets.each do |info|
41
+ type_label = info[:type] == :app ? '📱 App' : '🧩 Extension'
42
+ puts " #{type_label}: #{info[:name]}"
43
+ end
44
+ puts ""
45
+
46
+ print "Configure all targets? (y/n): "
47
+ confirm = get_input.downcase
48
+
49
+ unless confirm == 'y' || confirm == 'yes'
50
+ puts "Cancelled"
51
+ return
52
+ end
53
+
54
+ puts ""
55
+
56
+ # Configure each target
57
+ successful = 0
58
+ failed = 0
59
+
60
+ targets.each_with_index do |info, index|
61
+ puts ""
62
+ puts "=" * 80
63
+ puts "Configuring #{index + 1}/#{targets.count}: #{info[:name]}"
64
+ puts "=" * 80
65
+ puts ""
66
+
67
+ if configure_single_target(info[:name], skip_header: true)
68
+ successful += 1
69
+ else
70
+ failed += 1
71
+ puts ""
72
+ print "Continue with remaining targets? (y/n): "
73
+ continue = get_input.downcase
74
+ unless continue == 'y' || continue == 'yes'
75
+ break
76
+ end
77
+ end
78
+ end
79
+
80
+ puts ""
81
+ puts "=" * 80
82
+ puts "✅ Completed: #{successful} successful, #{failed} failed"
83
+ puts "=" * 80
84
+ puts ""
85
+ puts "Next steps:"
86
+ puts " 1. Test build: mysigner build"
87
+ puts " 2. Or ship to TestFlight: mysigner ship testflight"
88
+ puts ""
89
+ end
90
+
91
+ def configure_single_target(target_name = nil, skip_header: false)
92
+ unless skip_header
93
+ # No header needed, already printed in run!
94
+ end
95
+
96
+ # Step 1: Detect or validate target
97
+ target_name = detect_target(target_name)
98
+ return false unless target_name
99
+
100
+ @current_target = target_name
101
+
102
+ # Step 1.5: Check if project's team matches current org
103
+ check_org_team_match(target_name) if @options[:check_org_match] != false
104
+
105
+ # Step 2: Show current configuration
106
+ show_current_config(target_name)
107
+
108
+ # Step 3: Select team
109
+ team_id = select_team(target_name)
110
+ return false unless team_id
111
+
112
+ # Step 4: Select provisioning profile
113
+ profile = select_profile(target_name, team_id)
114
+ return false unless profile
115
+
116
+ # Step 5: Apply configuration
117
+ apply_configuration(target_name, team_id, profile)
118
+
119
+ # Step 6: Validate
120
+ validate_configuration(target_name, team_id)
121
+
122
+ unless skip_header
123
+ puts ""
124
+ puts "=" * 80
125
+ puts "✅ Signing configuration complete!"
126
+ puts "=" * 80
127
+ puts ""
128
+ puts "Next steps:"
129
+ puts " 1. Test build: mysigner build"
130
+ puts " 2. Or ship to TestFlight: mysigner ship testflight"
131
+ puts ""
132
+ end
133
+
134
+ true
135
+ end
136
+
137
+ private
138
+
139
+ def detect_target(target_name = nil)
140
+ # If target_name provided, validate it exists
141
+ if target_name
142
+ begin
143
+ @parser.find_target(target_name)
144
+ puts "📱 Target: #{target_name}"
145
+ puts ""
146
+ return target_name
147
+ rescue => e
148
+ error "Target '#{target_name}' not found: #{e.message}"
149
+ return nil
150
+ end
151
+ end
152
+
153
+ # No target provided, auto-detect or let user choose
154
+ targets = @parser.app_targets
155
+
156
+ if targets.empty?
157
+ error "No app targets found in project"
158
+ return nil
159
+ end
160
+
161
+ if targets.count == 1
162
+ target = targets.first
163
+ puts "📱 Target: #{target.name}"
164
+ puts ""
165
+ return target.name
166
+ end
167
+
168
+ # Multiple targets - let user choose
169
+ puts "Multiple app targets found:"
170
+ targets.each_with_index do |target, i|
171
+ puts " #{i + 1}. #{target.name}"
172
+ end
173
+ puts ""
174
+
175
+ print "Select target (1-#{targets.count}): "
176
+ choice = get_input.to_i
177
+
178
+ if choice < 1 || choice > targets.count
179
+ error "Invalid selection"
180
+ return nil
181
+ end
182
+
183
+ targets[choice - 1].name
184
+ end
185
+
186
+ def show_current_config(target_name)
187
+ puts "Current Configuration:"
188
+ puts "-" * 80
189
+
190
+ bundle_id = @parser.bundle_id(target_name)
191
+ puts " Bundle ID: #{bundle_id || 'Not set'}"
192
+
193
+ team_id = @parser.team_id(target_name)
194
+ puts " Team: #{team_id || 'Not set'}"
195
+
196
+ sign_style = @parser.code_sign_style(target_name)
197
+ puts " Signing: #{sign_style || 'Not set'}"
198
+
199
+ if @parser.signing_configured?(target_name)
200
+ profile_name = @parser.project.targets.find { |t| t.name == target_name }
201
+ &.build_configurations&.first
202
+ &.build_settings&.[]('PROVISIONING_PROFILE_SPECIFIER')
203
+ puts " Profile: #{profile_name || 'Auto'}"
204
+ end
205
+
206
+ puts ""
207
+ end
208
+
209
+ def select_team(target_name)
210
+ puts "Step 1: Select Development Team"
211
+ puts "-" * 80
212
+ puts ""
213
+
214
+ current_team = @parser.team_id(target_name)
215
+
216
+ # Option 1: Keep current team
217
+ if current_team && !current_team.empty?
218
+ puts " 1. Keep current team: #{current_team}"
219
+ end
220
+
221
+ # Option 2: Fetch from API
222
+ puts " #{current_team ? '2' : '1'}. Fetch from My Signer API"
223
+
224
+ # Option 3: Enter manually
225
+ puts " #{current_team ? '3' : '2'}. Enter team ID manually"
226
+
227
+ puts ""
228
+ print "Select option: "
229
+ choice = get_input.to_i
230
+
231
+ case choice
232
+ when 1
233
+ if current_team
234
+ puts "✓ Using current team: #{current_team}"
235
+ puts ""
236
+ return current_team
237
+ else
238
+ # Fetch from API
239
+ fetch_team_from_api
240
+ end
241
+ when 2
242
+ if current_team
243
+ fetch_team_from_api
244
+ else
245
+ enter_team_manually
246
+ end
247
+ when 3
248
+ enter_team_manually
249
+ else
250
+ error "Invalid selection"
251
+ nil
252
+ end
253
+ end
254
+
255
+ def fetch_team_from_api
256
+ puts ""
257
+ puts "Fetching team from My Signer..."
258
+
259
+ begin
260
+ response = @client.get("/api/v1/organizations/#{@organization_id}")
261
+ team_id = response.dig(:data, 'app_store_connect_team_id') || response['app_store_connect_team_id']
262
+
263
+ if team_id && !team_id.empty?
264
+ puts "✓ Found team: #{team_id}"
265
+ puts ""
266
+ return team_id
267
+ else
268
+ error "Team ID not saved in My Signer API (database)"
269
+ puts ""
270
+ current_team = @parser.team_id(@current_target)
271
+ puts "Note: Your Xcode project already has team: #{current_team}" if current_team
272
+ puts ""
273
+ puts "You can either:"
274
+ puts " 1. Keep your current Xcode team (go back and select option 1)"
275
+ puts " 2. Add it to My Signer web: https://mysigner.dev"
276
+ puts " → Open your organization → App Store Connect → Edit/Add credentials → Team ID field"
277
+ puts " 3. Enter it manually (go back and select option 3)"
278
+ puts ""
279
+ nil
280
+ end
281
+ rescue => e
282
+ error "Failed to fetch team: #{e.message}"
283
+ nil
284
+ end
285
+ end
286
+
287
+ def enter_team_manually
288
+ puts ""
289
+ print "Enter Team ID (10 characters): "
290
+ team_id = get_input
291
+
292
+ if team_id =~ /^[A-Z0-9]{10}$/
293
+ puts "✓ Team ID: #{team_id}"
294
+ puts ""
295
+ return team_id
296
+ else
297
+ error "Invalid Team ID format (must be 10 alphanumeric characters)"
298
+ nil
299
+ end
300
+ end
301
+
302
+ def select_profile(target_name, team_id)
303
+ puts "Step 2: Select Provisioning Profile"
304
+ puts "-" * 80
305
+ puts ""
306
+
307
+ # Fetch profiles from API
308
+ puts "Fetching provisioning profiles..."
309
+
310
+ begin
311
+ bundle_id = @parser.bundle_id(target_name)
312
+
313
+ # Get profiles for this bundle ID
314
+ response = @client.get("/api/v1/organizations/#{@organization_id}/profiles",
315
+ params: { bundle_id: bundle_id })
316
+
317
+ profiles = response[:data]['profiles'] || []
318
+
319
+ if profiles.empty?
320
+ puts "No provisioning profiles found for bundle ID: #{bundle_id}"
321
+ puts ""
322
+ puts "Options:"
323
+ puts " 1. Auto-create App Store profile (recommended)"
324
+ puts " 2. Auto-create Development profile"
325
+ puts " 3. Create manually and sync"
326
+ puts " 4. Skip"
327
+ puts ""
328
+
329
+ print "Select option (1-4): "
330
+ choice = get_input
331
+ puts ""
332
+
333
+ case choice
334
+ when "1"
335
+ profile = auto_create_profile(bundle_id, :appstore)
336
+ return profile if profile
337
+ return nil
338
+ when "2"
339
+ profile = auto_create_profile(bundle_id, :development)
340
+ return profile if profile
341
+ return nil
342
+ when "3"
343
+ puts "Create profile at: https://developer.apple.com/account/resources/profiles/add"
344
+ puts "Then sync from My Signer web dashboard"
345
+ puts ""
346
+ return nil
347
+ when "4"
348
+ puts "Skipped profile selection"
349
+ return nil
350
+ else
351
+ error "Invalid selection"
352
+ return nil
353
+ end
354
+ end
355
+
356
+ # Filter profiles by type (development vs distribution)
357
+ dev_profiles = profiles.select { |p| p['profile_type']&.include?('DEVELOPMENT') }
358
+ dist_profiles = profiles.select do |p|
359
+ type = p['profile_type']
360
+ type&.include?('DISTRIBUTION') || type&.include?('APP_STORE') || type&.include?('ADHOC') || type&.include?('INHOUSE')
361
+ end
362
+
363
+ puts ""
364
+ puts "Available Profiles:"
365
+ puts ""
366
+
367
+ all_profiles = []
368
+
369
+ if dev_profiles.any?
370
+ puts " Development Profiles:"
371
+ dev_profiles.each_with_index do |profile, i|
372
+ all_profiles << profile
373
+ status = profile['status'] == 'ACTIVE' ? '✓' : '✗'
374
+ puts " #{all_profiles.count}. #{status} #{profile['name']}"
375
+ puts " Expires: #{profile['expires_at']&.split('T')&.first || 'Unknown'}"
376
+ end
377
+ puts ""
378
+ end
379
+
380
+ if dist_profiles.any?
381
+ puts " Distribution Profiles:"
382
+ dist_profiles.each_with_index do |profile, i|
383
+ all_profiles << profile
384
+ status = profile['status'] == 'ACTIVE' ? '✓' : '✗'
385
+ puts " #{all_profiles.count}. #{status} #{profile['name']}"
386
+ puts " Expires: #{profile['expires_at']&.split('T')&.first || 'Unknown'}"
387
+ end
388
+ puts ""
389
+ end
390
+
391
+ print "Select profile (1-#{all_profiles.count}): "
392
+ choice = get_input.to_i
393
+
394
+ if choice < 1 || choice > all_profiles.count
395
+ error "Invalid selection"
396
+ return nil
397
+ end
398
+
399
+ selected = all_profiles[choice - 1]
400
+ puts "✓ Selected: #{selected['name']}"
401
+ puts ""
402
+
403
+ # Download and install the profile
404
+ download_and_install_profile(selected)
405
+
406
+ selected
407
+
408
+ rescue => e
409
+ error "Failed to fetch profiles: #{e.message}"
410
+ nil
411
+ end
412
+ end
413
+
414
+ def download_and_install_profile(profile)
415
+ puts "Downloading and installing profile..."
416
+
417
+ begin
418
+ # Download profile using direct Faraday connection for binary data
419
+ # (the client's get method uses JSON middleware which corrupts binary data)
420
+ download_url = "/api/v1/organizations/#{@organization_id}/profiles/#{profile['id']}/download"
421
+
422
+ conn = Faraday.new(url: @client.api_url) do |f|
423
+ f.request :authorization, 'Bearer', @client.api_token
424
+ f.adapter Faraday.default_adapter
425
+ end
426
+
427
+ response = conn.get(download_url) do |req|
428
+ req.options.timeout = 30
429
+ req.options.open_timeout = 10
430
+ end
431
+
432
+ unless response.success?
433
+ if response.headers['content-type']&.include?('json')
434
+ begin
435
+ error_data = JSON.parse(response.body)
436
+ raise "Download failed: #{error_data['message'] || error_data['error']}"
437
+ rescue JSON::ParserError
438
+ raise "Download failed with status #{response.status}"
439
+ end
440
+ else
441
+ raise "Download failed with status #{response.status}"
442
+ end
443
+ end
444
+
445
+ profile_content = response.body
446
+
447
+ # Create profiles directory if it doesn't exist
448
+ profiles_dir = File.expand_path("~/Library/MobileDevice/Provisioning Profiles")
449
+ FileUtils.mkdir_p(profiles_dir) unless Dir.exist?(profiles_dir)
450
+
451
+ # Generate filename (use UUID if available, otherwise sanitized name)
452
+ uuid = profile['uuid'] || profile['id']
453
+ filename = "#{uuid}.mobileprovision"
454
+ output_path = File.join(profiles_dir, filename)
455
+
456
+ # Write binary profile to file
457
+ File.binwrite(output_path, profile_content)
458
+
459
+ puts "✓ Profile installed: #{output_path}"
460
+ puts ""
461
+
462
+ rescue => e
463
+ # Non-fatal error - profile might still work if already installed
464
+ puts "⚠️ Could not auto-install profile: #{e.message}"
465
+ puts " You may need to install it manually by double-clicking the .mobileprovision file"
466
+ puts ""
467
+ end
468
+ end
469
+
470
+ def apply_configuration(target_name, team_id, profile)
471
+ puts "Step 3: Applying Configuration"
472
+ puts "-" * 80
473
+ puts ""
474
+
475
+ puts "Setting up manual signing for target: #{target_name}"
476
+ puts " Team: #{team_id}"
477
+ puts " Profile: #{profile['name']}"
478
+ puts ""
479
+
480
+ target = @parser.project.targets.find { |t| t.name == target_name }
481
+
482
+ unless target
483
+ raise WizardError, "Target not found: #{target_name}"
484
+ end
485
+
486
+ # Update build configurations
487
+ target.build_configurations.each do |config|
488
+ puts " Configuring #{config.name}..."
489
+
490
+ # Set manual signing
491
+ config.build_settings['CODE_SIGN_STYLE'] = 'Manual'
492
+
493
+ # Set team
494
+ config.build_settings['DEVELOPMENT_TEAM'] = team_id
495
+
496
+ # Set provisioning profile
497
+ config.build_settings['PROVISIONING_PROFILE_SPECIFIER'] = profile['name']
498
+
499
+ # Set code sign identity
500
+ profile_type = profile['profile_type']
501
+ if profile_type&.include?('DEVELOPMENT')
502
+ config.build_settings['CODE_SIGN_IDENTITY'] = 'Apple Development'
503
+ else
504
+ config.build_settings['CODE_SIGN_IDENTITY'] = 'Apple Distribution'
505
+ end
506
+ end
507
+
508
+ # Save project
509
+ @parser.project.save
510
+
511
+ puts "✓ Configuration applied"
512
+ puts ""
513
+ end
514
+
515
+ def validate_configuration(target_name, team_id)
516
+ puts "Step 4: Validating Configuration"
517
+ puts "-" * 80
518
+ puts ""
519
+
520
+ validator = Validator.new(@parser, target_name, 'Release', team_id: team_id)
521
+ result = validator.validate
522
+
523
+ if result[:valid]
524
+ puts "✓ Configuration is valid"
525
+
526
+ if result[:warnings].any?
527
+ puts ""
528
+ puts "Warnings:"
529
+ result[:warnings].each do |warning|
530
+ puts " ⚠️ #{warning}"
531
+ end
532
+ end
533
+ else
534
+ puts "✗ Configuration has errors:"
535
+ puts ""
536
+ result[:errors].each do |error|
537
+ puts " • #{error}"
538
+ end
539
+ puts ""
540
+ raise WizardError, "Configuration validation failed"
541
+ end
542
+
543
+ puts ""
544
+ end
545
+
546
+ def check_org_team_match(target_name)
547
+ project_team = @parser.team_id(target_name)
548
+ return unless project_team # No team set in project yet
549
+
550
+ begin
551
+ # Fetch current org's team
552
+ response = @client.get("/api/v1/organizations/#{@organization_id}")
553
+ org_name = response.dig(:data, 'name') || response['name']
554
+ org_team = response.dig(:data, 'app_store_connect_team_id') || response['app_store_connect_team_id']
555
+
556
+ if org_team && org_team != project_team
557
+ puts ""
558
+ puts "⚠️ Warning: Organization / Team Mismatch", :yellow
559
+ puts "=" * 80
560
+ puts ""
561
+ puts "Your Xcode project uses team: #{project_team}"
562
+ puts "Current My Signer org: #{org_name} (Team: #{org_team})"
563
+ puts ""
564
+ puts "This means your project belongs to a different Apple Developer account"
565
+ puts "than the organization you're currently using in My Signer."
566
+ puts ""
567
+ puts "What this means:"
568
+ puts " • Profiles/certificates fetched will be for team #{org_team}"
569
+ puts " • But your project needs resources for team #{project_team}"
570
+ puts " • This will likely cause signing errors"
571
+ puts ""
572
+ puts "To fix this:"
573
+ puts " 1. Exit this wizard (Ctrl+C)"
574
+ puts " 2. Run: mysigner switch"
575
+ puts " 3. Select the organization that has team #{project_team}"
576
+ puts " 4. Run this wizard again"
577
+ puts ""
578
+ print "Continue anyway? (y/N): "
579
+ answer = get_input.downcase
580
+
581
+ unless answer == 'y' || answer == 'yes'
582
+ puts ""
583
+ puts "Wizard cancelled. Please switch organizations and try again."
584
+ exit 0
585
+ end
586
+ puts ""
587
+ elsif !org_team
588
+ # Current org has no team configured
589
+ puts ""
590
+ puts "ℹ️ Note: Current organization has no Team ID configured", :cyan
591
+ puts "=" * 80
592
+ puts ""
593
+ puts "Your Xcode project uses team: #{project_team}"
594
+ puts "Current My Signer org: #{org_name} (No team configured)"
595
+ puts ""
596
+ puts "You can continue, but the wizard won't be able to fetch the team from My Signer."
597
+ puts "Consider adding Team ID to this org at: https://mysigner.dev"
598
+ puts ""
599
+ print "Continue? (Y/n): "
600
+ answer = get_input.downcase
601
+
602
+ if answer == 'n' || answer == 'no'
603
+ puts ""
604
+ puts "Wizard cancelled."
605
+ exit 0
606
+ end
607
+ puts ""
608
+ end
609
+ rescue => e
610
+ # Ignore errors in org checking - don't block the wizard
611
+ puts "Warning: Could not verify organization match: #{e.message}" if ENV['DEBUG']
612
+ end
613
+ end
614
+
615
+ def auto_create_profile(bundle_id, type)
616
+ puts "Creating #{type} profile for #{bundle_id}..."
617
+ puts ""
618
+
619
+ profile_type = type == :appstore ? 'IOS_APP_STORE' : 'IOS_APP_DEVELOPMENT'
620
+
621
+ begin
622
+ # Sync first to ensure we have latest resources
623
+ puts " Syncing organization resources..."
624
+ @client.post("/api/v1/organizations/#{@organization_id}/sync_app_store_connect")
625
+
626
+ # Wait for sync
627
+ sleep 2
628
+
629
+ # Check sync status
630
+ max_wait = 15
631
+ waited = 0
632
+
633
+ while waited < max_wait
634
+ status_response = @client.get("/api/v1/organizations/#{@organization_id}/sync/status")
635
+ sync_data = status_response[:data]['sync']
636
+
637
+ if !sync_data['running']
638
+ break
639
+ end
640
+
641
+ sleep 1
642
+ waited += 1
643
+ end
644
+
645
+ puts " ✓ Sync complete"
646
+ puts ""
647
+
648
+ # Create profile
649
+ puts " Creating #{profile_type} profile..."
650
+ response = @client.post(
651
+ "/api/v1/organizations/#{@organization_id}/profiles/auto_create",
652
+ body: {
653
+ bundle_id: bundle_id,
654
+ profile_type: profile_type
655
+ }
656
+ )
657
+
658
+ profile = response[:data]['profile']
659
+ puts " ✓ Created profile: #{profile['name']}"
660
+ puts ""
661
+
662
+ # Download and install
663
+ download_and_install_profile(profile)
664
+
665
+ profile
666
+ rescue Mysigner::ClientError => e
667
+ error_msg = e.message
668
+
669
+ if error_msg.include?("bundle_id_not_found")
670
+ error "Bundle ID '#{bundle_id}' not found"
671
+ puts ""
672
+ puts "Register it at: https://developer.apple.com/account/resources/identifiers/add"
673
+ puts "Then sync in the web dashboard"
674
+ elsif error_msg.include?("certificates found") || error_msg.include?("no_certificates")
675
+ cert_name = type == :appstore ? "Apple Distribution" : "Apple Development"
676
+
677
+ error "No #{cert_name} certificates found"
678
+ puts ""
679
+
680
+ print "Generate CSR automatically? [Y/n] "
681
+ response = get_input.downcase
682
+
683
+ if response.empty? || response == 'y' || response == 'yes'
684
+ puts ""
685
+ csr_path = generate_csr_for_wizard
686
+
687
+ if csr_path
688
+ puts ""
689
+ puts " ✓ CSR ready: #{File.basename(csr_path)}"
690
+ puts ""
691
+ puts " 📋 Next steps:"
692
+ puts " 1. https://developer.apple.com/account/resources/certificates/add"
693
+ puts " 2. Select: '#{cert_name}' (or older 'iOS' variant if available)"
694
+ puts " 3. Upload: #{csr_path}"
695
+ puts " 4. Download .cer → Double-click → Sync → Try again"
696
+ puts ""
697
+ end
698
+ else
699
+ puts ""
700
+ puts "Quick fix:"
701
+ puts " 1. Open Keychain Access → Request Certificate (save CSR)"
702
+ puts " 2. https://developer.apple.com/account/resources/certificates/add"
703
+ puts " 3. Select '#{cert_name}' → Upload CSR → Download .cer"
704
+ puts " 4. Double-click .cer → Sync My Signer → Try again"
705
+ puts ""
706
+ end
707
+ elsif error_msg.include?("no_devices") || error_msg.include?("devices found")
708
+ error "No test devices registered"
709
+ puts ""
710
+ puts "Quick fix:"
711
+ puts " • Get UDID: Connect device → Finder → Click serial number"
712
+ puts " • Run: mysigner device add <UDID> <NAME>"
713
+ puts ""
714
+ else
715
+ error "Failed to create profile: #{error_msg}"
716
+ end
717
+ puts ""
718
+ nil
719
+ rescue => e
720
+ error "Unexpected error: #{e.message}"
721
+ puts ""
722
+ nil
723
+ end
724
+ end
725
+
726
+ def generate_csr_for_wizard
727
+ require 'openssl'
728
+
729
+ begin
730
+ # Save to Downloads (visible in file picker)
731
+ csr_dir = File.expand_path("~/Downloads")
732
+ FileUtils.mkdir_p(csr_dir)
733
+
734
+ # Generate RSA key pair
735
+ key = OpenSSL::PKey::RSA.new(2048)
736
+
737
+ # Create CSR
738
+ csr = OpenSSL::X509::Request.new
739
+ csr.version = 0
740
+ csr.subject = OpenSSL::X509::Name.new([
741
+ ['CN', 'My Signer User'],
742
+ ['emailAddress', 'user@example.com']
743
+ ])
744
+ csr.public_key = key.public_key
745
+ csr.sign(key, OpenSSL::Digest::SHA256.new)
746
+
747
+ # Generate unique filename
748
+ timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
749
+ csr_filename = "CertificateSigningRequest_#{timestamp}.certSigningRequest"
750
+ key_filename = "private_key_#{timestamp}.pem"
751
+
752
+ # Save CSR to Downloads (visible)
753
+ csr_path = File.join(csr_dir, csr_filename)
754
+
755
+ # Save private key to hidden location (secure)
756
+ key_dir = File.expand_path("~/.mysigner/keys")
757
+ FileUtils.mkdir_p(key_dir)
758
+ key_path = File.join(key_dir, key_filename)
759
+
760
+ # Save files
761
+ File.write(csr_path, csr.to_pem)
762
+ File.write(key_path, key.to_pem)
763
+ File.chmod(0600, key_path)
764
+
765
+ csr_path
766
+ rescue => e
767
+ puts " ✗ Failed to generate CSR: #{e.message}"
768
+ nil
769
+ end
770
+ end
771
+
772
+ # Safely get user input, returns empty string if STDIN is closed or nil
773
+ def get_input
774
+ input = STDIN.gets
775
+ input ? input.strip : ''
776
+ end
777
+
778
+ def error(message)
779
+ puts "✗ #{message}"
780
+ end
781
+ end
782
+ end
783
+ end
784
+