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,2670 @@
1
+ module Mysigner
2
+ class CLI < Thor
3
+ module ResourceCommands
4
+ def self.included(base)
5
+ base.class_eval do
6
+ desc "devices", "List registered test devices (UDIDs)"
7
+ method_option :platform, type: :string, aliases: '-p', desc: 'Filter by platform (IOS, MAC_OS, TV_OS)'
8
+ method_option :status, type: :string, aliases: '-s', desc: 'Filter by status (ENABLED, DISABLED)'
9
+ method_option :search, type: :string, aliases: '-q', desc: 'Search by name or UDID'
10
+ method_option :page, type: :numeric, default: 1, desc: 'Page number'
11
+ method_option :per_page, type: :numeric, default: 50, desc: 'Devices per page'
12
+ def devices
13
+ config = load_config
14
+ client = create_client(config)
15
+
16
+ say "📱 Devices", :cyan
17
+ say ""
18
+
19
+ # Build query params
20
+ params = {
21
+ page: options[:page],
22
+ per_page: options[:per_page]
23
+ }
24
+ params[:platform] = options[:platform].upcase if options[:platform]
25
+ params[:status] = options[:status].upcase if options[:status]
26
+ params[:q] = options[:search] if options[:search]
27
+
28
+ begin
29
+ response = client.get("/api/v1/organizations/#{config.current_organization_id}/devices", params: params)
30
+ devices = response[:data]['devices']
31
+ pagination = response[:data]['pagination']
32
+
33
+ if devices.empty?
34
+ say "No devices found", :yellow
35
+ say ""
36
+ say "Tip: Register a device with 'mysigner device add NAME UDID'", :yellow
37
+ return
38
+ end
39
+
40
+ # Display devices
41
+ devices.each do |device|
42
+ status_icon = device['status'] == 'ENABLED' ? '✓' : '✗'
43
+ status_color = device['status'] == 'ENABLED' ? :green : :red
44
+
45
+ say " #{status_icon} #{device['name']} (ID: #{device['id']})", status_color
46
+ say " UDID: #{device['udid']}"
47
+ say " Platform: #{device['platform']} | Class: #{device['device_class']}"
48
+ say " Status: #{device['status']}"
49
+ say ""
50
+ end
51
+
52
+ # Show pagination
53
+ say "Page #{pagination['page']} of #{pagination['total_pages']} (#{pagination['total']} total)", :yellow
54
+
55
+ if pagination['page'] < pagination['total_pages']
56
+ say "Run with --page #{pagination['page'] + 1} to see more", :yellow
57
+ end
58
+ rescue Mysigner::ClientError => e
59
+ error "Failed to fetch devices: #{e.message}"
60
+ exit 1
61
+ end
62
+ end
63
+
64
+ desc "device SUBCOMMAND", "Manage test devices (detect, add, update)"
65
+ long_desc <<~DESC
66
+ Register and manage test devices for development builds.
67
+
68
+ WHY REGISTER DEVICES?
69
+
70
+ To install development/adhoc builds on physical devices, you must register
71
+ their UDIDs (Unique Device Identifiers) with Apple and include them in your
72
+ provisioning profiles.
73
+
74
+ SUBCOMMANDS:
75
+
76
+ mysigner device detect
77
+ Auto-detect connected iOS devices and show their UDIDs
78
+
79
+ mysigner device add NAME UDID [--platform IOS]
80
+ Register a new device for testing
81
+
82
+ mysigner device update ID NEW_NAME
83
+ Rename an existing device
84
+
85
+ HOW TO GET A DEVICE UDID:
86
+
87
+ Method 1 - Auto-detect (Recommended):
88
+ mysigner device detect
89
+
90
+ This will find all connected iOS devices and let you register them
91
+ interactively. No need to open any other apps.
92
+
93
+ Method 2 - Via Finder:
94
+ 1. Connect your iPhone/iPad to your Mac
95
+ 2. Open Finder and select your device in the sidebar
96
+ 3. Click on the device info to reveal UDID
97
+ 4. Right-click → Copy UDID
98
+
99
+ EXAMPLES:
100
+
101
+ # Register your iPhone
102
+ mysigner device add "My iPhone 15" 00008030-001A1B2C3D4E567F
103
+
104
+ # Register an iPad
105
+ mysigner device add "Test iPad" da83bb40dba39e35d258988d856508798db7afba
106
+
107
+ # Register a Mac for Mac Catalyst apps
108
+ mysigner device add "MacBook Pro" ABC123... --platform MAC_OS
109
+
110
+ # Rename a device (use ID from 'mysigner devices' list)
111
+ mysigner device update 42 "John's iPhone"
112
+
113
+ NOTES:
114
+
115
+ • UDIDs are 40 hex characters (0-9, a-f) or 25 characters for newer devices
116
+ • You can register up to 100 devices per year per account
117
+ • After registering, regenerate your provisioning profiles to include the device
118
+ • Run 'mysigner devices' to see all registered devices
119
+ DESC
120
+ method_option :platform, type: :string, default: 'IOS', aliases: '-p', desc: 'Platform (IOS, MAC_OS, TV_OS)'
121
+ def device(action, *args)
122
+ config = load_config
123
+ client = create_client(config)
124
+
125
+ case action
126
+ when 'detect'
127
+ detect_connected_devices(config, client)
128
+ when 'add'
129
+ if args.length < 2
130
+ error "Usage: mysigner device add NAME UDID [--platform IOS]"
131
+ say ""
132
+ say "Example: mysigner device add \"My iPhone\" 00008030-001A1B2C3D4E567F", :yellow
133
+ say ""
134
+ say "💡 Don't know your UDID? Run:", :cyan
135
+ say " mysigner device detect", :cyan
136
+ say ""
137
+ say " This will auto-detect connected devices and let you register them.", :cyan
138
+ exit 1
139
+ end
140
+
141
+ name = args[0]
142
+ udid = args[1]
143
+ platform = options[:platform].upcase
144
+
145
+ say "📱 Registering device...", :cyan
146
+ say ""
147
+
148
+ begin
149
+ response = client.post(
150
+ "/api/v1/organizations/#{config.current_organization_id}/devices",
151
+ body: {
152
+ name: name,
153
+ udid: udid,
154
+ platform: platform
155
+ }
156
+ )
157
+
158
+ device = response[:data]['device']
159
+ say "✓ Device registered successfully!", :green
160
+ say ""
161
+ say "Details:", :bold
162
+ say " Name: #{device['name']}"
163
+ say " UDID: #{device['udid']}"
164
+ say " Platform: #{device['platform']}"
165
+ say " Status: #{device['status']}"
166
+ rescue Mysigner::ValidationError => e
167
+ error "Validation failed:"
168
+ if e.details
169
+ e.details.each do |field, errors|
170
+ say " #{field}: #{errors.join(', ')}", :red
171
+ end
172
+ else
173
+ say " #{e.message}", :red
174
+ end
175
+ exit 1
176
+ rescue Mysigner::ClientError => e
177
+ if e.message.include?("already exists")
178
+ error "Device with this UDID already exists"
179
+ else
180
+ error "Failed to register device: #{e.message}"
181
+ end
182
+ exit 1
183
+ end
184
+ when 'update'
185
+ if args.length < 2
186
+ error "Usage: mysigner device update ID NEW_NAME"
187
+ say ""
188
+ say "Example: mysigner device update 42 \"John's iPhone\"", :yellow
189
+ say ""
190
+ say "💡 To get device IDs:", :cyan
191
+ say " Run 'mysigner devices' to see all devices with their IDs", :cyan
192
+ exit 1
193
+ end
194
+
195
+ device_id = args[0]
196
+ new_name = args[1]
197
+
198
+ say "📱 Updating device...", :cyan
199
+ say ""
200
+
201
+ begin
202
+ # Get device details first
203
+ response = client.get("/api/v1/organizations/#{config.current_organization_id}/devices/#{device_id}")
204
+ device = response[:data]
205
+
206
+ say "Current name: #{device['name']}", :yellow
207
+ say "New name: #{new_name}", :green
208
+ say ""
209
+
210
+ # Update device
211
+ response = client.patch(
212
+ "/api/v1/organizations/#{config.current_organization_id}/devices/#{device_id}",
213
+ body: { name: new_name }
214
+ )
215
+
216
+ updated_device = response[:data]['device']
217
+ say "✓ Device updated successfully!", :green
218
+ say ""
219
+ say "Details:", :bold
220
+ say " Name: #{updated_device['name']}"
221
+ say " UDID: #{updated_device['udid']}"
222
+ say " Platform: #{updated_device['platform']}"
223
+ rescue Mysigner::NotFoundError
224
+ error "Device not found with ID: #{device_id}"
225
+ exit 1
226
+ rescue Mysigner::ClientError => e
227
+ error "Failed to update device: #{e.message}"
228
+ exit 1
229
+ end
230
+ when 'help'
231
+ invoke :help, ['device']
232
+ else
233
+ error "Unknown action: #{action}"
234
+ say "Available actions: detect, add, update, help", :yellow
235
+ exit 1
236
+ end
237
+ end
238
+
239
+ private
240
+
241
+ def detect_connected_devices(config, client)
242
+ say "🔍 Detecting connected iOS devices...", :cyan
243
+ say ""
244
+
245
+ devices = []
246
+
247
+ # Try system_profiler first (built-in macOS)
248
+ if system("which system_profiler > /dev/null 2>&1")
249
+ output = `system_profiler SPUSBDataType 2>/dev/null`
250
+
251
+ # Parse iOS devices from system_profiler output
252
+ current_device = nil
253
+ output.each_line do |line|
254
+ if line =~ /^\s{4}(\S.+):$/
255
+ # New top-level USB device
256
+ current_device = { name: $1.strip }
257
+ elsif current_device
258
+ if line =~ /Serial Number:\s*([A-Fa-f0-9-]+)/
259
+ serial = $1.gsub('-', '')
260
+ # iOS device serials are typically 24-40 hex chars
261
+ if serial.length >= 24 && serial.length <= 40
262
+ current_device[:udid] = serial
263
+ end
264
+ elsif line =~ /Product ID:\s*(0x12[aA][0-9a-fA-F])/
265
+ # Apple mobile device product IDs start with 0x12a
266
+ current_device[:is_ios] = true
267
+ end
268
+ end
269
+
270
+ if current_device && current_device[:udid] && current_device[:is_ios]
271
+ devices << current_device
272
+ current_device = nil
273
+ end
274
+ end
275
+ end
276
+
277
+ # Also try idevice_id if available (more reliable)
278
+ if system("which idevice_id > /dev/null 2>&1")
279
+ output = `idevice_id -l 2>/dev/null`.strip
280
+ output.each_line do |line|
281
+ udid = line.strip
282
+ next if udid.empty?
283
+
284
+ # Get device name if ideviceinfo is available
285
+ name = "iOS Device"
286
+ if system("which ideviceinfo > /dev/null 2>&1")
287
+ device_name = `ideviceinfo -u #{udid} -k DeviceName 2>/dev/null`.strip
288
+ name = device_name unless device_name.empty?
289
+ end
290
+
291
+ # Avoid duplicates
292
+ unless devices.any? { |d| d[:udid] == udid }
293
+ devices << { name: name, udid: udid }
294
+ end
295
+ end
296
+ end
297
+
298
+ # Also try xcrun xctrace (Xcode command line tools)
299
+ if devices.empty? && system("which xcrun > /dev/null 2>&1")
300
+ output = `xcrun xctrace list devices 2>/dev/null`
301
+ in_devices_section = false
302
+
303
+ output.each_line do |line|
304
+ line = line.strip
305
+
306
+ # Track sections
307
+ if line == "== Devices =="
308
+ in_devices_section = true
309
+ next
310
+ elsif line == "== Simulators =="
311
+ in_devices_section = false
312
+ next
313
+ end
314
+
315
+ next unless in_devices_section
316
+ next if line.empty?
317
+
318
+ # Format: "Name (Version) (UDID)" - iOS devices have UDIDs starting with 0000
319
+ # Skip Macs which have UUID format
320
+ if line =~ /^(.+?)\s+\([^)]+\)\s+\((0000[A-Fa-f0-9-]+)\)\s*$/
321
+ name = $1.strip
322
+ udid = $2.gsub('-', '')
323
+ devices << { name: name, udid: udid }
324
+ end
325
+ end
326
+ end
327
+
328
+ if devices.empty?
329
+ say "No iOS devices detected.", :yellow
330
+ say ""
331
+ say "Make sure:", :cyan
332
+ say " 1. Your device is connected via USB", :cyan
333
+ say " 2. The device is unlocked", :cyan
334
+ say " 3. You've trusted this computer on the device", :cyan
335
+ say ""
336
+ say "💡 For better detection, install libimobiledevice:", :yellow
337
+ say " brew install libimobiledevice", :yellow
338
+ return
339
+ end
340
+
341
+ say "Found #{devices.length} device(s):", :green
342
+ say ""
343
+
344
+ devices.each_with_index do |device, idx|
345
+ say " #{idx + 1}. #{device[:name]}", :green
346
+ say " UDID: #{device[:udid]}", :white
347
+ say ""
348
+ end
349
+
350
+ # Check if running interactively
351
+ return unless $stdin.tty?
352
+
353
+ # Ask if user wants to register
354
+ say "Would you like to register a device? (Enter number, or 'n' to skip)", :cyan
355
+ choice = ask(">")&.strip || ''
356
+
357
+ return if choice.downcase == 'n' || choice.empty?
358
+
359
+ idx = choice.to_i - 1
360
+ if idx >= 0 && idx < devices.length
361
+ device = devices[idx]
362
+
363
+ say ""
364
+ say "Enter a name for this device (or press Enter to use '#{device[:name]}'):", :cyan
365
+ custom_name = ask(">")&.strip || ''
366
+ name = custom_name.empty? ? device[:name] : custom_name
367
+
368
+ say ""
369
+ say "📱 Registering '#{name}'...", :cyan
370
+
371
+ begin
372
+ response = client.post(
373
+ "/api/v1/organizations/#{config.current_organization_id}/devices",
374
+ body: {
375
+ name: name,
376
+ udid: device[:udid],
377
+ platform: 'IOS'
378
+ }
379
+ )
380
+
381
+ registered = response[:data]['device']
382
+ say ""
383
+ say "✓ Device registered successfully!", :green
384
+ say " Name: #{registered['name']}"
385
+ say " UDID: #{registered['udid']}"
386
+ say " Platform: #{registered['platform']}"
387
+ rescue Mysigner::ClientError => e
388
+ if e.message.include?("already exists")
389
+ say ""
390
+ say "ℹ️ Device already registered", :yellow
391
+ else
392
+ error "Failed to register: #{e.message}"
393
+ end
394
+ end
395
+ else
396
+ say "Invalid selection", :red
397
+ end
398
+ end
399
+
400
+ public
401
+
402
+ desc "profiles", "List provisioning profiles (advanced - only needed for manual signing)"
403
+ long_desc <<~DESC
404
+ List all provisioning profiles in your organization.
405
+
406
+ WHEN DO YOU NEED THIS?
407
+
408
+ For Automatic Signing (Most Users):
409
+ ❌ You DON'T need this - Xcode handles everything
410
+
411
+ For Manual Signing (Advanced):
412
+ ✅ View available profiles
413
+ ✅ Check expiration dates
414
+ ✅ Get profile IDs for download/delete
415
+
416
+ EXAMPLES:
417
+
418
+ # List all profiles
419
+ mysigner profiles
420
+
421
+ # Filter by type
422
+ mysigner profiles --type APP_STORE
423
+ mysigner profiles --type DEVELOPMENT
424
+
425
+ # Filter by status
426
+ mysigner profiles --status EXPIRED
427
+
428
+ # Search by name
429
+ mysigner profiles --search "MyApp"
430
+
431
+ NOTE: Most users can skip this and just run 'mysigner build'
432
+ DESC
433
+ method_option :type, type: :string, aliases: '-t', desc: 'Filter by type (DEVELOPMENT, AD_HOC, APP_STORE, ENTERPRISE)'
434
+ method_option :status, type: :string, aliases: '-s', desc: 'Filter by status (ACTIVE, EXPIRED, INVALID)'
435
+ method_option :search, type: :string, aliases: '-q', desc: 'Search by name or identifier'
436
+ method_option :page, type: :numeric, default: 1, desc: 'Page number'
437
+ method_option :per_page, type: :numeric, default: 50, desc: 'Profiles per page'
438
+ def profiles
439
+ config = load_config
440
+ client = create_client(config)
441
+
442
+ say "📄 Provisioning Profiles", :cyan
443
+ say ""
444
+
445
+ # Build query params
446
+ params = {
447
+ page: options[:page],
448
+ per_page: options[:per_page]
449
+ }
450
+ params[:type] = options[:type].upcase if options[:type]
451
+ params[:state] = options[:status].upcase if options[:status]
452
+ params[:q] = options[:search] if options[:search]
453
+
454
+ begin
455
+ response = client.get("/api/v1/organizations/#{config.current_organization_id}/profiles", params: params)
456
+ profiles = response[:data]['profiles']
457
+ pagination = response[:data]['pagination']
458
+
459
+ if profiles.empty?
460
+ say "No profiles found", :yellow
461
+ say ""
462
+ say "Tip: Profiles are created automatically when you request code signing", :yellow
463
+ return
464
+ end
465
+
466
+ # Display profiles
467
+ profiles.each do |profile|
468
+ status_icon = profile['status'] == 'ACTIVE' ? '✓' : '✗'
469
+ status_color = profile['status'] == 'ACTIVE' ? :green : :red
470
+
471
+ say " #{status_icon} #{profile['name']}", status_color
472
+ say " ID: #{profile['id']} | Type: #{profile['profile_type'] || 'N/A'}"
473
+ say " Bundle ID: #{profile['bundle_id'] || 'N/A'}"
474
+ say " Status: #{profile['status'] || 'UNKNOWN'}"
475
+
476
+ if profile['expires_at']
477
+ expires = Time.parse(profile['expires_at']).strftime('%Y-%m-%d')
478
+ say " Expires: #{expires}"
479
+ end
480
+
481
+ say ""
482
+ end
483
+
484
+ # Show pagination
485
+ say "Page #{pagination['page']} of #{pagination['total_pages']} (#{pagination['total']} total)", :yellow
486
+
487
+ if pagination['page'] < pagination['total_pages']
488
+ say "Run with --page #{pagination['page'] + 1} to see more", :yellow
489
+ end
490
+ rescue Mysigner::ClientError => e
491
+ error "Failed to fetch profiles: #{e.message}"
492
+ exit 1
493
+ end
494
+ end
495
+
496
+ desc "profile SUBCOMMAND", "Download or delete profiles (advanced - only needed for manual signing)"
497
+ long_desc <<~DESC
498
+ Manage provisioning profiles for code signing.
499
+
500
+ WHAT ARE PROVISIONING PROFILES?
501
+
502
+ Provisioning profiles are required for signing iOS apps. They link:
503
+ - Your signing certificate
504
+ - Your App ID (bundle ID)
505
+ - Authorized devices (for development/ad-hoc)
506
+
507
+ WHEN DO YOU NEED THIS?
508
+
509
+ For Automatic Signing (Most Users):
510
+ ❌ You DON'T need these commands
511
+ ✅ Xcode handles profiles automatically
512
+ ✅ Just run: mysigner build
513
+
514
+ For Manual Signing (Advanced):
515
+ ✅ Download profiles from My Signer
516
+ ✅ Install them to ~/Library/MobileDevice/Provisioning Profiles/
517
+ ✅ Delete old/expired profiles
518
+
519
+ SUBCOMMANDS:
520
+
521
+ mysigner profile download ID [--output path]
522
+ Download a provisioning profile
523
+
524
+ mysigner profile delete ID
525
+ Delete a provisioning profile
526
+
527
+ HOW TO USE:
528
+
529
+ 1. List available profiles:
530
+ mysigner profiles
531
+
532
+ 2. Download a profile:
533
+ mysigner profile download 1
534
+
535
+ 3. Install it (double-click or manual):
536
+ open Profile_Name.mobileprovision
537
+ # Or: cp *.mobileprovision ~/Library/MobileDevice/Provisioning\\ Profiles/
538
+
539
+ EXAMPLES:
540
+
541
+ # Download profile ID 1
542
+ mysigner profile download 1
543
+
544
+ # Download to specific location
545
+ mysigner profile download 1 --output ~/Desktop/MyProfile.mobileprovision
546
+
547
+ # Delete expired profile
548
+ mysigner profile delete 5
549
+
550
+ # List all profiles
551
+ mysigner profiles
552
+
553
+ # Filter by type
554
+ mysigner profiles --type APP_STORE
555
+
556
+ NOTES:
557
+
558
+ • Most users with Automatic signing don't need this
559
+ • Manual signing wizard tries to auto-install profiles
560
+ • Profiles expire after 1 year and must be regenerated
561
+ • Development profiles: For testing on devices
562
+ • App Store profiles: For production releases
563
+ DESC
564
+ method_option :output, type: :string, aliases: '-o', desc: 'Output file path (default: profile name)'
565
+ def profile(action, *args)
566
+ config = load_config
567
+ client = create_client(config)
568
+
569
+ case action
570
+ when 'download'
571
+ if args.empty?
572
+ error "Usage: mysigner profile download ID [--output path.mobileprovision]"
573
+ say ""
574
+ say "Example: mysigner profile download 1", :yellow
575
+ say ""
576
+ say "💡 To get profile IDs:", :cyan
577
+ say " Run 'mysigner profiles' to see all profiles with their IDs", :cyan
578
+ say ""
579
+ say "Note: Most users with Automatic signing don't need this", :yellow
580
+ say "Run 'mysigner help profile' for more info", :cyan
581
+ exit 1
582
+ end
583
+
584
+ profile_id = args[0]
585
+
586
+ say "📄 Downloading profile...", :cyan
587
+ say ""
588
+
589
+ begin
590
+ # Get profile details first
591
+ response = client.get("/api/v1/organizations/#{config.current_organization_id}/profiles/#{profile_id}")
592
+ profile = response[:data]
593
+
594
+ # Determine output path
595
+ output_path = if options[:output]
596
+ options[:output]
597
+ else
598
+ # Use profile name, sanitize it for filename
599
+ name = profile['name'] || "profile_#{profile['id']}"
600
+ filename = name.gsub(/[^0-9A-Za-z.\-]/, '_')
601
+ "#{filename}.mobileprovision"
602
+ end
603
+
604
+ # Download the profile content using the client's connection with auth
605
+ download_url = "/api/v1/organizations/#{config.current_organization_id}/profiles/#{profile_id}/download"
606
+
607
+ say "Fetching profile content...", :yellow
608
+
609
+ # Use Faraday directly with proper auth for binary download
610
+ conn = Faraday.new(url: config.api_url) do |f|
611
+ f.request :authorization, 'Bearer', config.api_token
612
+ f.adapter Faraday.default_adapter
613
+ end
614
+
615
+ response = conn.get(download_url) do |req|
616
+ req.options.timeout = 30 # 30 second timeout
617
+ req.options.open_timeout = 10 # 10 second connection timeout
618
+ end
619
+
620
+ unless response.success?
621
+ # Check if it's a JSON error response
622
+ if response.headers['content-type']&.include?('json')
623
+ begin
624
+ error_data = JSON.parse(response.body)
625
+ error "Download failed: #{error_data['message'] || error_data['error']}"
626
+ rescue
627
+ error "Download failed with status #{response.status}"
628
+ end
629
+ else
630
+ error "Download failed with status #{response.status}"
631
+ end
632
+ exit 1
633
+ end
634
+
635
+ # Write binary content directly to file
636
+ File.binwrite(output_path, response.body)
637
+
638
+ say "✓ Profile downloaded successfully!", :green
639
+ say ""
640
+ say "Details:", :bold
641
+ say " Name: #{profile['name']}"
642
+ say " Type: #{profile['profile_type'] || 'N/A'}"
643
+ say " Bundle ID: #{profile['bundle_id_identifier'] || 'N/A'}"
644
+ say " Status: #{profile['state'] || 'UNKNOWN'}"
645
+ say " File: #{output_path}"
646
+ say ""
647
+ say "File size: #{response.body.bytesize} bytes", :yellow
648
+ rescue Mysigner::NotFoundError
649
+ error "Profile not found with ID: #{profile_id}"
650
+ say ""
651
+ say "💡 Profile Not Found: How to fix", :cyan
652
+ say ""
653
+ say " → List available profiles: mysigner profiles", :yellow
654
+ say " → Sync from Apple: mysigner sync ios", :yellow
655
+ say " → Check ID is correct (IDs are numeric)", :yellow
656
+ say ""
657
+ exit 1
658
+ rescue Mysigner::ClientError => e
659
+ error "Failed to download profile: #{e.message}"
660
+ say ""
661
+ say "💡 Download Failed: Try these steps", :cyan
662
+ say ""
663
+ say " → Check your network connection", :yellow
664
+ say " → Verify API token is valid: mysigner status", :yellow
665
+ say " → Re-authenticate if needed: mysigner login", :yellow
666
+ say ""
667
+ exit 1
668
+ rescue => e
669
+ error "Failed to save file: #{e.message}"
670
+ say ""
671
+ say "💡 File Save Failed: Check these", :cyan
672
+ say ""
673
+ say " → Verify you have write permissions to the directory", :yellow
674
+ say " → Check disk space is available", :yellow
675
+ say " → Try specifying a different output path with --output", :yellow
676
+ say ""
677
+ exit 1
678
+ end
679
+ when 'delete'
680
+ if args.empty?
681
+ error "Usage: mysigner profile delete ID"
682
+ say ""
683
+ say "Example: mysigner profile delete 5", :yellow
684
+ say ""
685
+ say "💡 To get profile IDs:", :cyan
686
+ say " Run 'mysigner profiles' to see all profiles with their IDs", :cyan
687
+ exit 1
688
+ end
689
+
690
+ profile_id = args[0]
691
+
692
+ say "📄 Deleting profile...", :cyan
693
+ say ""
694
+
695
+ begin
696
+ # Get profile details first
697
+ response = client.get("/api/v1/organizations/#{config.current_organization_id}/profiles/#{profile_id}")
698
+ profile = response[:data]
699
+
700
+ # Confirm deletion
701
+ say "You are about to delete:", :yellow
702
+ say " Name: #{profile['name']}"
703
+ say " Type: #{profile['profile_type']}"
704
+ say " Bundle ID: #{profile['bundle_id_identifier'] || 'N/A'}"
705
+ say ""
706
+
707
+ if yes?("Are you sure you want to delete this profile? (y/n)")
708
+ client.delete("/api/v1/organizations/#{config.current_organization_id}/profiles/#{profile_id}")
709
+ say ""
710
+ say "✓ Profile deleted successfully!", :green
711
+ else
712
+ say "Deletion cancelled", :yellow
713
+ end
714
+ rescue Mysigner::NotFoundError
715
+ error "Profile not found with ID: #{profile_id}"
716
+ exit 1
717
+ rescue Mysigner::ClientError => e
718
+ error "Failed to delete profile: #{e.message}"
719
+ exit 1
720
+ end
721
+ when 'help'
722
+ invoke :help, ['profile']
723
+ else
724
+ error "Unknown action: #{action}"
725
+ say "Available actions: download, delete, help", :yellow
726
+ exit 1
727
+ end
728
+ end
729
+
730
+ desc "certificates", "List signing certificates from App Store Connect"
731
+ method_option :type, type: :string, aliases: '-p', desc: 'Filter by type (DEVELOPMENT, DISTRIBUTION)'
732
+ method_option :status, type: :string, aliases: '-s', desc: 'Filter by status (ACTIVE, EXPIRED, REVOKED)'
733
+ method_option :search, type: :string, aliases: '-q', desc: 'Search by name'
734
+ method_option :page, type: :numeric, default: 1, desc: 'Page number'
735
+ method_option :per_page, type: :numeric, default: 50, desc: 'Certificates per page'
736
+ def certificates
737
+ config = load_config
738
+ client = create_client(config)
739
+
740
+ say "🔐 Signing Certificates", :cyan
741
+ say ""
742
+
743
+ # Build query params
744
+ params = {
745
+ page: options[:page],
746
+ per_page: options[:per_page]
747
+ }
748
+ params[:certificate_type] = options[:type].upcase if options[:type]
749
+ params[:status] = options[:status].upcase if options[:status]
750
+ params[:q] = options[:search] if options[:search]
751
+
752
+ begin
753
+ response = client.get("/api/v1/organizations/#{config.current_organization_id}/certificates", params: params)
754
+ certificates = response[:data]['certificates']
755
+ pagination = response[:data]['pagination']
756
+
757
+ if certificates.empty?
758
+ say "No certificates found", :yellow
759
+ say ""
760
+ say "Tip: Certificates are synced automatically from App Store Connect", :yellow
761
+ return
762
+ end
763
+
764
+ # Display certificates
765
+ certificates.each do |cert|
766
+ status_icon = cert['status'] == 'ACTIVE' ? '✓' : '✗'
767
+ status_color = cert['status'] == 'ACTIVE' ? :green : :red
768
+
769
+ say " #{status_icon} #{cert['name']}", status_color
770
+ say " ID: #{cert['id']} | Type: #{cert['certificate_type'] || 'N/A'}"
771
+ say " Serial: #{cert['serial_number'] || 'N/A'}"
772
+ say " Status: #{cert['status'] || 'UNKNOWN'}"
773
+
774
+ if cert['expires_at']
775
+ expires = Time.parse(cert['expires_at']).strftime('%Y-%m-%d')
776
+ say " Expires: #{expires}"
777
+ end
778
+
779
+ say ""
780
+ end
781
+
782
+ # Show pagination
783
+ say "Page #{pagination['page']} of #{pagination['total_pages']} (#{pagination['total']} total)", :yellow
784
+
785
+ if pagination['page'] < pagination['total_pages']
786
+ say "Run with --page #{pagination['page'] + 1} to see more", :yellow
787
+ end
788
+ rescue Mysigner::ClientError => e
789
+ error "Failed to fetch certificates: #{e.message}"
790
+ exit 1
791
+ end
792
+ end
793
+
794
+ desc "certificate ACTION", "Check local keychain or download certificates (check, download)"
795
+ long_desc <<~DESC
796
+ Actions:
797
+ check - Check certificates installed in your Mac's Keychain (not API)
798
+ download ID - Download a certificate from My Signer API
799
+
800
+ Note: 'check' scans your LOCAL Keychain, not certificates in My Signer API.
801
+ Use 'mysigner certificates' to see API certificates.
802
+ DESC
803
+ method_option :output, type: :string, aliases: '-o', desc: 'Output file path (default: certificate name)'
804
+ def certificate(action, *args)
805
+ config = load_config
806
+ client = create_client(config)
807
+
808
+ case action
809
+ when 'check'
810
+ require_relative '../signing/certificate_checker'
811
+
812
+ say "🔍 Checking local certificates...", :cyan
813
+ say ""
814
+
815
+ checker = Signing::CertificateChecker.new
816
+
817
+ begin
818
+ certificates = checker.check!
819
+
820
+ if certificates.empty?
821
+ say "No code signing certificates found in local Keychain", :yellow
822
+ say ""
823
+ say "⚠️ Important:", :yellow
824
+ say " This command checks certificates INSTALLED ON YOUR MAC.", :white
825
+ say " Certificates in My Signer API are not automatically installed locally.", :white
826
+ say ""
827
+ say "To install certificates:", :cyan
828
+ say " 1. List certificates in My Signer: mysigner certificates", :white
829
+ say " 2. Download one: mysigner certificate download <ID>", :white
830
+ say " 3. Double-click the .cer file to install in Keychain", :white
831
+ say ""
832
+ say "Or download from Apple Developer:", :cyan
833
+ say " https://developer.apple.com/account/resources/certificates/list", :white
834
+ return
835
+ end
836
+
837
+ # Group by status
838
+ by_status = checker.by_status
839
+
840
+ # Show valid certificates
841
+ if by_status[:valid].any?
842
+ say "✓ Valid Certificates (#{by_status[:valid].count})", :green
843
+ say ""
844
+ by_status[:valid].each do |cert|
845
+ say " #{cert[:name]}", :green
846
+ say " Type: #{cert[:type]}"
847
+ say " Team: #{cert[:team_id] || 'Unknown'}"
848
+ say " Expires: #{cert[:expires_at].strftime('%Y-%m-%d')} (#{cert[:days_until_expiry]} days)", :white
849
+ say ""
850
+ end
851
+ end
852
+
853
+ # Show expiring soon certificates
854
+ if by_status[:expiring_soon].any?
855
+ say "⚠️ Expiring Soon (#{by_status[:expiring_soon].count})", :yellow
856
+ say ""
857
+ by_status[:expiring_soon].each do |cert|
858
+ say " #{cert[:name]}", :yellow
859
+ say " Type: #{cert[:type]}"
860
+ say " Team: #{cert[:team_id] || 'Unknown'}"
861
+ say " Expires: #{cert[:expires_at].strftime('%Y-%m-%d')} (#{cert[:days_until_expiry]} days)", :yellow
862
+ say ""
863
+ end
864
+ say "Renew these certificates soon to avoid build failures!", :yellow
865
+ say ""
866
+ end
867
+
868
+ # Show expired certificates
869
+ if by_status[:expired].any?
870
+ say "✗ Expired Certificates (#{by_status[:expired].count})", :red
871
+ say ""
872
+ by_status[:expired].each do |cert|
873
+ say " #{cert[:name]}", :red
874
+ say " Type: #{cert[:type]}"
875
+ say " Team: #{cert[:team_id] || 'Unknown'}"
876
+ say " Expired: #{cert[:expires_at].strftime('%Y-%m-%d')} (#{cert[:days_until_expiry].abs} days ago)", :red
877
+ say ""
878
+ end
879
+ say "These certificates will cause build failures. Renew them at:", :red
880
+ say " https://developer.apple.com/account/resources/certificates/list", :white
881
+ say ""
882
+ end
883
+
884
+ # Summary
885
+ say "─" * 80, :cyan
886
+ say "Total: #{certificates.count} certificate#{certificates.count == 1 ? '' : 's'} installed locally", :cyan
887
+ if checker.has_issues?
888
+ say "Status: ⚠️ Action required", :yellow
889
+ else
890
+ say "Status: ✓ All certificates valid", :green
891
+ end
892
+ say ""
893
+ say "💡 Tip: These are certificates INSTALLED ON YOUR MAC.", :cyan
894
+ say " To see all certificates in My Signer API, run: mysigner certificates", :white
895
+
896
+ rescue Signing::CertificateChecker::CheckError => e
897
+ error "Certificate check failed: #{e.message}"
898
+ say ""
899
+ say "This usually means:", :yellow
900
+ say " • Keychain is locked", :white
901
+ say " • No certificates installed", :white
902
+ say " • Security command not available", :white
903
+ exit 1
904
+ end
905
+
906
+ when 'download'
907
+ if args.empty?
908
+ error "Usage: mysigner certificate download ID [--output path.cer]"
909
+ exit 1
910
+ end
911
+
912
+ certificate_id = args[0]
913
+
914
+ say "🔐 Downloading certificate...", :cyan
915
+ say ""
916
+
917
+ begin
918
+ # Get certificate details first
919
+ response = client.get("/api/v1/organizations/#{config.current_organization_id}/certificates/#{certificate_id}")
920
+ certificate = response[:data]
921
+
922
+ # Determine output path
923
+ output_path = if options[:output]
924
+ options[:output]
925
+ else
926
+ # Use certificate name, sanitize it for filename
927
+ name = certificate['name'] || "certificate_#{certificate['id']}"
928
+ filename = name.gsub(/[^0-9A-Za-z.\-]/, '_')
929
+ "#{filename}.cer"
930
+ end
931
+
932
+ # Download the certificate content (binary response)
933
+ download_url = "/api/v1/organizations/#{config.current_organization_id}/certificates/#{certificate_id}/download"
934
+
935
+ say "Fetching certificate content...", :yellow
936
+
937
+ # Use Faraday directly with proper auth for binary download
938
+ conn = Faraday.new(url: config.api_url) do |f|
939
+ f.request :authorization, 'Bearer', config.api_token
940
+ f.adapter Faraday.default_adapter
941
+ end
942
+
943
+ response = conn.get(download_url) do |req|
944
+ req.options.timeout = 30 # 30 second timeout
945
+ req.options.open_timeout = 10 # 10 second connection timeout
946
+ end
947
+
948
+ unless response.success?
949
+ # Check if it's a JSON error response
950
+ if response.headers['content-type']&.include?('json')
951
+ begin
952
+ error_data = JSON.parse(response.body)
953
+ error "Download failed: #{error_data['message'] || error_data['error']}"
954
+ rescue
955
+ error "Download failed with status #{response.status}"
956
+ end
957
+ else
958
+ error "Download failed with status #{response.status}"
959
+ end
960
+ exit 1
961
+ end
962
+
963
+ # Write binary content directly to file
964
+ File.binwrite(output_path, response.body)
965
+
966
+ say "✓ Certificate downloaded successfully!", :green
967
+ say ""
968
+ say "Details:", :bold
969
+ say " Name: #{certificate['name']}"
970
+ say " Type: #{certificate['certificate_type'] || 'N/A'}"
971
+ say " Serial: #{certificate['serial_number'] || 'N/A'}"
972
+ say " Status: #{certificate['status'] || 'UNKNOWN'}"
973
+ say " File: #{output_path}"
974
+ say ""
975
+ say "File size: #{response.body.bytesize} bytes", :yellow
976
+ rescue Mysigner::NotFoundError
977
+ error "Certificate not found with ID: #{certificate_id}"
978
+ say ""
979
+ say "💡 Certificate Not Found: How to fix", :cyan
980
+ say ""
981
+ say " → List available certificates: mysigner certificates", :yellow
982
+ say " → Sync from Apple: mysigner sync ios", :yellow
983
+ say " → Check the ID is correct (IDs are numeric)", :yellow
984
+ say ""
985
+ exit 1
986
+ rescue Mysigner::ClientError => e
987
+ error "Failed to download certificate: #{e.message}"
988
+ say ""
989
+ say "💡 Download Failed: Try these steps", :cyan
990
+ say ""
991
+ say " → Check your network connection", :yellow
992
+ say " → Verify API token is valid: mysigner status", :yellow
993
+ say " → Re-authenticate if needed: mysigner login", :yellow
994
+ say ""
995
+ exit 1
996
+ rescue => e
997
+ error "Failed to save file: #{e.message}"
998
+ say ""
999
+ say "💡 File Save Failed: Check these", :cyan
1000
+ say ""
1001
+ say " → Verify you have write permissions to the directory", :yellow
1002
+ say " → Check disk space is available", :yellow
1003
+ say " → Try specifying a different output path with --output", :yellow
1004
+ say ""
1005
+ exit 1
1006
+ end
1007
+ when 'help'
1008
+ invoke :help, ['certificate']
1009
+ else
1010
+ error "Unknown action: #{action}"
1011
+ say "Available actions: download, help", :yellow
1012
+ exit 1
1013
+ end
1014
+ end
1015
+
1016
+ # ==================== ANDROID KEYSTORES ====================
1017
+
1018
+ desc "keystore SUBCOMMAND", "Manage Android keystores (list, upload, download, delete, activate)"
1019
+ long_desc <<~DESC
1020
+ Manage Android keystores for signing your apps.
1021
+
1022
+ WHAT IS A KEYSTORE?
1023
+
1024
+ A keystore (.jks or .keystore file) contains the private key used to sign
1025
+ your Android app. The same keystore must be used for all updates to your app.
1026
+
1027
+ SUBCOMMANDS:
1028
+
1029
+ mysigner keystore list
1030
+ List all keystores
1031
+
1032
+ mysigner keystore upload PATH
1033
+ Upload a new keystore to My Signer
1034
+
1035
+ mysigner keystore download ID
1036
+ Download a keystore to use locally
1037
+
1038
+ mysigner keystore delete ID
1039
+ Delete a keystore from My Signer
1040
+
1041
+ mysigner keystore activate ID
1042
+ Set a keystore as the active/default one
1043
+
1044
+ EXAMPLES:
1045
+
1046
+ # List all keystores
1047
+ mysigner keystore list
1048
+
1049
+ # Upload your release keystore
1050
+ mysigner keystore upload ~/keys/release.jks
1051
+
1052
+ # Download keystore ID 1
1053
+ mysigner keystore download 1
1054
+
1055
+ # Make keystore ID 2 the default
1056
+ mysigner keystore activate 2
1057
+
1058
+ # Delete old keystore
1059
+ mysigner keystore delete 3
1060
+
1061
+ SECURITY:
1062
+
1063
+ • Keystores are stored encrypted on My Signer servers
1064
+ • Downloaded keystores are stored in ~/.mysigner/keystores/
1065
+ • Never commit keystores to version control
1066
+ • Keep backup copies in a secure location
1067
+ DESC
1068
+ method_option :name, type: :string, desc: 'Name for the keystore'
1069
+ method_option :alias, type: :string, desc: 'Key alias in the keystore'
1070
+ method_option :app_id, type: :numeric, desc: 'Associate with Android app ID'
1071
+ method_option :output, type: :string, aliases: '-o', desc: 'Output path for download'
1072
+ def keystore(action, *args)
1073
+ config = load_config
1074
+ client = create_client(config)
1075
+
1076
+ require_relative '../signing/keystore_manager'
1077
+ manager = Signing::KeystoreManager.new(client, config.current_organization_id)
1078
+
1079
+ case action
1080
+ when 'list'
1081
+ say "🔐 Android Keystores", :cyan
1082
+ say ""
1083
+
1084
+ keystores = manager.list(android_app_id: options[:app_id])
1085
+
1086
+ if keystores.empty?
1087
+ say "No keystores found", :yellow
1088
+ say ""
1089
+ say "Upload a keystore with: mysigner keystore upload PATH", :yellow
1090
+ return
1091
+ end
1092
+
1093
+ keystores.each do |ks|
1094
+ active_icon = ks['active'] ? '✓' : '○'
1095
+ active_color = ks['active'] ? :green : :white
1096
+
1097
+ say " #{active_icon} #{ks['name']} (ID: #{ks['id']})", active_color
1098
+ say " Key Alias: #{ks['key_alias'] || 'N/A'}"
1099
+ say " App: #{ks['package_name']}" if ks['package_name']
1100
+ say " Active: #{ks['active'] ? 'Yes' : 'No'}"
1101
+ say ""
1102
+ end
1103
+
1104
+ say "Total: #{keystores.count} keystore(s)", :yellow
1105
+
1106
+ when 'upload'
1107
+ keystore_path = args[0]
1108
+
1109
+ unless keystore_path
1110
+ error "Usage: mysigner keystore upload PATH"
1111
+ say ""
1112
+ say "Example: mysigner keystore upload ~/keys/release.jks", :yellow
1113
+ exit 1
1114
+ end
1115
+
1116
+ unless File.exist?(keystore_path)
1117
+ error "File not found: #{keystore_path}"
1118
+ exit 1
1119
+ end
1120
+
1121
+ say "🔐 Uploading keystore...", :cyan
1122
+ say ""
1123
+
1124
+ # Get keystore details
1125
+ name = options[:name] || ask("Keystore name (e.g., 'Release Key'):")
1126
+ key_alias = options[:alias] || ask("Key alias:")
1127
+ password = ask("Keystore password:", echo: false)
1128
+ say ""
1129
+ key_password = ask("Key password (press Enter if same as keystore):", echo: false)
1130
+ say ""
1131
+ key_password = password if key_password.empty?
1132
+
1133
+ begin
1134
+ result = manager.upload(
1135
+ name: name,
1136
+ keystore_path: keystore_path,
1137
+ keystore_password: password,
1138
+ key_alias: key_alias,
1139
+ key_password: key_password,
1140
+ android_app_id: options[:app_id],
1141
+ active: true
1142
+ )
1143
+
1144
+ say "✓ Keystore uploaded successfully!", :green
1145
+ say ""
1146
+ say "Details:", :bold
1147
+ say " ID: #{result['id']}"
1148
+ say " Name: #{result['name']}"
1149
+ say " Key Alias: #{result['key_alias']}"
1150
+ say " Active: #{result['active']}"
1151
+ say ""
1152
+
1153
+ rescue Signing::KeystoreManager::KeystoreError => e
1154
+ error "Upload failed: #{e.message}"
1155
+ say ""
1156
+ say "💡 Keystore Upload Failed: Common issues", :cyan
1157
+ say ""
1158
+ say " → Verify the keystore file is valid (.jks or .keystore)", :yellow
1159
+ say " → Check keystore password is correct", :yellow
1160
+ say " → Check key alias exists in the keystore", :yellow
1161
+ say " → Verify key password is correct", :yellow
1162
+ say ""
1163
+ say " Test with: keytool -list -keystore #{keystore_path}", :green
1164
+ say ""
1165
+ exit 1
1166
+ rescue Mysigner::ClientError => e
1167
+ error "API error: #{e.message}"
1168
+ say ""
1169
+ say "💡 API Error: Try these steps", :cyan
1170
+ say ""
1171
+ say " → Check your network connection", :yellow
1172
+ say " → Verify API token is valid: mysigner status", :yellow
1173
+ say " → Re-authenticate if needed: mysigner login", :yellow
1174
+ say ""
1175
+ exit 1
1176
+ end
1177
+
1178
+ when 'download'
1179
+ keystore_id = args[0]
1180
+
1181
+ unless keystore_id
1182
+ error "Usage: mysigner keystore download ID"
1183
+ say ""
1184
+ say "Run 'mysigner keystores' to see available IDs", :yellow
1185
+ exit 1
1186
+ end
1187
+
1188
+ say "🔐 Downloading keystore...", :cyan
1189
+ say ""
1190
+
1191
+ begin
1192
+ result = manager.download(keystore_id)
1193
+
1194
+ # Move to custom output path if specified
1195
+ if options[:output]
1196
+ FileUtils.mv(result[:path], options[:output])
1197
+ result[:path] = options[:output]
1198
+ end
1199
+
1200
+ say "✓ Keystore downloaded!", :green
1201
+ say ""
1202
+ say "Details:", :bold
1203
+ say " Name: #{result[:name]}"
1204
+ say " Key Alias: #{result[:key_alias]}"
1205
+ say " Path: #{result[:path]}"
1206
+ say ""
1207
+ say "⚠️ Keep this file secure and backed up!", :yellow
1208
+
1209
+ rescue Signing::KeystoreManager::KeystoreNotFoundError => e
1210
+ error "Keystore not found: #{e.message}"
1211
+ say ""
1212
+ say "💡 Keystore Not Found: How to fix", :cyan
1213
+ say ""
1214
+ say " → List available keystores: mysigner keystores", :yellow
1215
+ say " → Upload a keystore: mysigner keystore upload <path>", :yellow
1216
+ say " → Check the ID is correct (IDs are numeric)", :yellow
1217
+ say ""
1218
+ exit 1
1219
+ rescue Signing::KeystoreManager::DownloadError => e
1220
+ error "Download failed: #{e.message}"
1221
+ say ""
1222
+ say "💡 Download Failed: Try these steps", :cyan
1223
+ say ""
1224
+ say " → Check your network connection", :yellow
1225
+ say " → Verify API token is valid: mysigner status", :yellow
1226
+ say " → Re-authenticate if needed: mysigner login", :yellow
1227
+ say ""
1228
+ exit 1
1229
+ end
1230
+
1231
+ when 'delete'
1232
+ keystore_id = args[0]
1233
+
1234
+ unless keystore_id
1235
+ error "Usage: mysigner keystore delete ID"
1236
+ exit 1
1237
+ end
1238
+
1239
+ # Get keystore details first
1240
+ keystores = manager.list
1241
+ keystore = keystores.find { |k| k['id'].to_s == keystore_id.to_s }
1242
+
1243
+ unless keystore
1244
+ error "Keystore not found with ID: #{keystore_id}"
1245
+ say ""
1246
+ say "💡 Keystore Not Found: How to fix", :cyan
1247
+ say ""
1248
+ say " → List available keystores: mysigner keystores", :yellow
1249
+ say " → Upload a keystore: mysigner keystore upload <path>", :yellow
1250
+ say ""
1251
+ exit 1
1252
+ end
1253
+
1254
+ say "⚠️ You are about to delete:", :yellow
1255
+ say " Name: #{keystore['name']}"
1256
+ say " Key Alias: #{keystore['key_alias']}"
1257
+ say ""
1258
+
1259
+ if yes?("Are you sure? This cannot be undone. (y/n)")
1260
+ begin
1261
+ manager.delete(keystore_id)
1262
+ say ""
1263
+ say "✓ Keystore deleted", :green
1264
+ rescue Mysigner::ClientError => e
1265
+ error "Delete failed: #{e.message}"
1266
+ exit 1
1267
+ end
1268
+ else
1269
+ say "Deletion cancelled", :yellow
1270
+ end
1271
+
1272
+ when 'activate'
1273
+ keystore_id = args[0]
1274
+
1275
+ unless keystore_id
1276
+ error "Usage: mysigner keystore activate ID"
1277
+ exit 1
1278
+ end
1279
+
1280
+ say "🔐 Activating keystore...", :cyan
1281
+
1282
+ begin
1283
+ result = manager.activate(keystore_id)
1284
+ say "✓ Keystore activated!", :green
1285
+ say ""
1286
+ say "#{result['name']} is now the default keystore", :cyan
1287
+ rescue Mysigner::NotFoundError
1288
+ error "Keystore not found with ID: #{keystore_id}"
1289
+ say ""
1290
+ say "💡 Keystore Not Found: How to fix", :cyan
1291
+ say ""
1292
+ say " → List available keystores: mysigner keystores", :yellow
1293
+ say " → Upload a keystore: mysigner keystore upload <path>", :yellow
1294
+ say ""
1295
+ exit 1
1296
+ rescue Mysigner::ClientError => e
1297
+ error "Activation failed: #{e.message}"
1298
+ say ""
1299
+ say "💡 Activation Failed: Try these steps", :cyan
1300
+ say ""
1301
+ say " → Verify keystore ID is correct: mysigner keystores", :yellow
1302
+ say " → Check API token is valid: mysigner status", :yellow
1303
+ say ""
1304
+ exit 1
1305
+ end
1306
+
1307
+ when 'help'
1308
+ invoke :help, ['keystore']
1309
+ else
1310
+ error "Unknown action: #{action}"
1311
+ say "Available actions: list, upload, download, delete, activate, help", :yellow
1312
+ exit 1
1313
+ end
1314
+ end
1315
+
1316
+ # ==================== ANDROID APP REGISTRATION ====================
1317
+
1318
+ desc "android SUBCOMMAND", "Android commands (init, add, build, list)"
1319
+ long_desc <<~DESC
1320
+ Register and manage Android apps with My Signer.
1321
+
1322
+ SUBCOMMANDS:
1323
+
1324
+ mysigner android init
1325
+ Auto-detect package name from current project and register.
1326
+ Works with native Android, React Native, Capacitor, and Expo.
1327
+
1328
+ mysigner android add PACKAGE_NAME [--name NAME]
1329
+ Manually register an app by package name.
1330
+
1331
+ mysigner android build
1332
+ Build an AAB file for upload to Google Play Console.
1333
+ Use this for your FIRST upload (required before mysigner ship works).
1334
+
1335
+ mysigner android list
1336
+ List registered Android apps (alias for 'mysigner apps --platform android')
1337
+
1338
+ FIRST-TIME SETUP:
1339
+
1340
+ Google Play requires the first build to be uploaded manually.
1341
+ After that, mysigner ship works automatically.
1342
+
1343
+ 1. mysigner android init # Register app
1344
+ 2. mysigner android build # Build AAB
1345
+ 3. Upload AAB in Play Console # One-time manual step
1346
+ 4. mysigner ship internal --platform android # Works from now on!
1347
+
1348
+ EXAMPLES:
1349
+
1350
+ # Auto-detect from project directory
1351
+ cd my-expo-app && mysigner android init
1352
+
1353
+ # Build AAB for first upload
1354
+ mysigner android build
1355
+
1356
+ # Manually add an app
1357
+ mysigner android add com.example.myapp --name "My App"
1358
+ DESC
1359
+ method_option :name, type: :string, desc: 'Display name for the app'
1360
+ def android(action, *args)
1361
+ config = load_config
1362
+ client = create_client(config)
1363
+
1364
+ case action
1365
+ when 'init'
1366
+ android_init(config, client)
1367
+ when 'add'
1368
+ if args.empty?
1369
+ error "Usage: mysigner android add PACKAGE_NAME [--name NAME]"
1370
+ say ""
1371
+ say "Example: mysigner android add com.example.myapp --name \"My App\"", :yellow
1372
+ exit 1
1373
+ end
1374
+ android_add(config, client, args[0], options[:name])
1375
+ when 'build'
1376
+ android_build
1377
+ when 'list'
1378
+ # Delegate to apps command with android platform
1379
+ invoke :apps, [], platform: 'android'
1380
+ when 'help'
1381
+ invoke :help, ['android']
1382
+ else
1383
+ error "Unknown action: #{action}"
1384
+ say "Available actions: init, add, build, list, help", :yellow
1385
+ exit 1
1386
+ end
1387
+ end
1388
+
1389
+ private
1390
+
1391
+ def android_init(config, client)
1392
+ require_relative '../build/android_parser'
1393
+
1394
+ say "🔍 Detecting project...", :cyan
1395
+ say ""
1396
+
1397
+ package_name = nil
1398
+ app_name = nil
1399
+ project_type = nil
1400
+
1401
+ # Try native/cross-platform detection first
1402
+ begin
1403
+ project_info = Build::Detector.detect_android(Dir.pwd)
1404
+ parser = Build::AndroidParser.new(project_info)
1405
+ package_name = parser.application_id
1406
+ app_name = parser.app_name
1407
+ project_type = project_info[:framework]&.to_s&.gsub('_', ' ')&.capitalize || 'Native Android'
1408
+ rescue Build::Detector::NoProjectError
1409
+ # Try Expo fallback
1410
+ expo_config = parse_expo_config(Dir.pwd)
1411
+ if expo_config && expo_config[:package_name]
1412
+ package_name = expo_config[:package_name]
1413
+ app_name = expo_config[:name]
1414
+ project_type = 'Expo managed'
1415
+ else
1416
+ error "No Android project or Expo config found"
1417
+ say ""
1418
+ say "For Expo managed projects, add 'android.package' to app.json:", :yellow
1419
+ say ""
1420
+ say " {", :white
1421
+ say " \"expo\": {", :white
1422
+ say " \"android\": { \"package\": \"com.yourcompany.app\" }", :white
1423
+ say " }", :white
1424
+ say " }", :white
1425
+ say ""
1426
+ say "Or run from a directory with an android/ folder (native/RN/Capacitor).", :yellow
1427
+ exit 1
1428
+ end
1429
+ end
1430
+
1431
+ say "✓ Found: #{project_type} project", :green
1432
+ say ""
1433
+ say "📦 Package: #{package_name}", :cyan
1434
+ say "📱 Name: #{app_name || '(not set)'}", :cyan
1435
+ say ""
1436
+
1437
+ # Check if app already exists
1438
+ begin
1439
+ response = client.get(
1440
+ "/api/v1/organizations/#{config.current_organization_id}/android_apps",
1441
+ params: { q: package_name }
1442
+ )
1443
+ existing_apps = response[:data]['android_apps'] || []
1444
+ existing = existing_apps.find { |a| a['package_name'] == package_name }
1445
+
1446
+ if existing
1447
+ say "ℹ️ App already registered!", :yellow
1448
+ say ""
1449
+ say "Details:", :bold
1450
+ say " ID: #{existing['id']}"
1451
+ say " Name: #{existing['name'] || '(not set)'}"
1452
+ say " Package: #{existing['package_name']}"
1453
+ say " Builds: #{existing['builds_count'] || 0}"
1454
+ say ""
1455
+ say "Next steps:", :cyan
1456
+ if (existing['builds_count'] || 0) > 0
1457
+ say " • Ship to Play Store: mysigner ship internal --platform android", :white
1458
+ else
1459
+ say " 1. Build AAB: mysigner android build", :white
1460
+ say " 2. Upload first build manually in Play Console (one-time requirement)", :white
1461
+ say " 3. After that: mysigner ship internal --platform android", :white
1462
+ end
1463
+ return
1464
+ end
1465
+ rescue Mysigner::ClientError => e
1466
+ # Ignore lookup errors, proceed with creation
1467
+ end
1468
+
1469
+ # Register the app
1470
+ say "🔗 Registering with My Signer...", :cyan
1471
+
1472
+ begin
1473
+ response = client.post(
1474
+ "/api/v1/organizations/#{config.current_organization_id}/android_apps",
1475
+ body: {
1476
+ package_name: package_name,
1477
+ name: app_name
1478
+ }
1479
+ )
1480
+
1481
+ app = response[:data]['android_app'] || response[:data]
1482
+ say "✓ App registered successfully!", :green
1483
+ say ""
1484
+ say "Details:", :bold
1485
+ say " ID: #{app['id']}"
1486
+ say " Name: #{app['name'] || '(not set)'}"
1487
+ say " Package: #{app['package_name']}"
1488
+ say ""
1489
+ say "Next steps:", :cyan
1490
+ say " 1. Create app in Google Play Console", :white
1491
+ say " 2. Build AAB: mysigner android build", :white
1492
+ say " 3. Upload first build manually in Play Console (one-time requirement)", :white
1493
+ say " 4. After that: mysigner ship internal --platform android", :white
1494
+
1495
+ rescue Mysigner::ValidationError => e
1496
+ error "Validation failed:"
1497
+ if e.details
1498
+ e.details.each do |field, errors|
1499
+ say " #{field}: #{errors.join(', ')}", :red
1500
+ end
1501
+ else
1502
+ say " #{e.message}", :red
1503
+ end
1504
+ exit 1
1505
+ rescue Mysigner::ClientError => e
1506
+ error "Failed to register app: #{e.message}"
1507
+ exit 1
1508
+ end
1509
+ end
1510
+
1511
+ def android_add(config, client, package_name, name = nil)
1512
+ say "📦 Registering Android app...", :cyan
1513
+ say ""
1514
+ say " Package: #{package_name}", :white
1515
+ say " Name: #{name || '(will be synced from Play Store)'}", :white
1516
+ say ""
1517
+
1518
+ begin
1519
+ response = client.post(
1520
+ "/api/v1/organizations/#{config.current_organization_id}/android_apps",
1521
+ body: {
1522
+ package_name: package_name,
1523
+ name: name
1524
+ }.compact
1525
+ )
1526
+
1527
+ app = response[:data]['android_app'] || response[:data]
1528
+ say "✓ App registered successfully!", :green
1529
+ say ""
1530
+ say "Details:", :bold
1531
+ say " ID: #{app['id']}"
1532
+ say " Name: #{app['name'] || '(not set)'}"
1533
+ say " Package: #{app['package_name']}"
1534
+ say ""
1535
+ say "Next steps:", :cyan
1536
+ if (app['builds_count'] || 0) > 0
1537
+ say " • Ship to Play Store: mysigner ship internal --platform android", :white
1538
+ else
1539
+ say " 1. Create app in Google Play Console (if not done)", :white
1540
+ say " 2. Build AAB: mysigner android build", :white
1541
+ say " 3. Upload first build manually in Play Console", :white
1542
+ say " 4. Then use: mysigner ship internal --platform android", :white
1543
+ end
1544
+
1545
+ rescue Mysigner::ValidationError => e
1546
+ error "Validation failed:"
1547
+ if e.details
1548
+ e.details.each do |field, errors|
1549
+ say " #{field}: #{errors.join(', ')}", :red
1550
+ end
1551
+ else
1552
+ say " #{e.message}", :red
1553
+ end
1554
+ exit 1
1555
+ rescue Mysigner::ClientError => e
1556
+ if e.message.include?("already exists") || e.message.include?("taken")
1557
+ error "An app with this package name already exists"
1558
+ say ""
1559
+ say "List your apps with: mysigner android list", :yellow
1560
+ else
1561
+ error "Failed to register app: #{e.message}"
1562
+ end
1563
+ exit 1
1564
+ end
1565
+ end
1566
+
1567
+ def android_build
1568
+ say "🔨 Building Android App Bundle (AAB)...", :cyan
1569
+ say ""
1570
+
1571
+ begin
1572
+ require_relative '../build/android_parser'
1573
+ require_relative '../signing/keystore_manager'
1574
+
1575
+ project_dir = Dir.pwd
1576
+ is_expo = expo_project?(project_dir)
1577
+
1578
+ # For Expo, we may need to regenerate android folder with correct versionCode
1579
+ # Get package name from app.json first if Expo
1580
+ if is_expo
1581
+ expo_config = parse_expo_config(project_dir)
1582
+ package_name = expo_config[:package_name]
1583
+ local_version_code = expo_config[:version_code] || 1
1584
+ version_name = expo_config[:version] || "1.0.0"
1585
+
1586
+ # Check highest version code from API
1587
+ highest_version_code = fetch_highest_version_code(package_name)
1588
+ version_code = local_version_code
1589
+ needs_increment = highest_version_code && local_version_code <= highest_version_code
1590
+
1591
+ if needs_increment
1592
+ version_code = highest_version_code + 1
1593
+
1594
+ # Check if android folder already has the correct versionCode
1595
+ android_dir = File.join(project_dir, 'android')
1596
+ current_android_version = nil
1597
+ if Dir.exist?(android_dir)
1598
+ build_gradle = File.join(android_dir, 'app', 'build.gradle')
1599
+ if File.exist?(build_gradle)
1600
+ content = File.read(build_gradle)
1601
+ if content =~ /versionCode\s+(\d+)/
1602
+ current_android_version = $1.to_i
1603
+ end
1604
+ end
1605
+ end
1606
+
1607
+ say "🔧 Framework: Expo (React Native)", :white
1608
+ say "📦 Package: #{package_name}", :white
1609
+
1610
+ if current_android_version == version_code
1611
+ # Android folder already has correct version, no need to regenerate
1612
+ say "🔢 Version: #{version_name} (#{version_code})", :white
1613
+ say " ↳ Already at correct version code", :green
1614
+ else
1615
+ say "🔢 Version: #{version_name} (#{local_version_code} → #{version_code})", :white
1616
+ say " ↳ Auto-incremented (#{highest_version_code} already on Play Store)", :yellow
1617
+ say ""
1618
+
1619
+ # Regenerate android folder with new versionCode
1620
+ say "🔄 Regenerating android folder with version code #{version_code}...", :yellow
1621
+ regenerate_expo_android(project_dir, version_code)
1622
+ end
1623
+ end
1624
+ end
1625
+
1626
+ # Now detect project (android folder should exist)
1627
+ project_info = Build::Detector.detect_android(project_dir)
1628
+ framework = project_info[:framework]
1629
+ parser = Build::AndroidParser.new(project_info)
1630
+
1631
+ package_name ||= parser.application_id
1632
+ version_name ||= parser.version_name
1633
+ local_version_code ||= parser.version_code.to_i
1634
+ android_dir = project_info[:android_directory] || File.join(project_info[:directory], 'android')
1635
+
1636
+ # For non-Expo, check version code now
1637
+ unless is_expo
1638
+ say "🔧 Framework: #{framework.to_s.gsub('_', ' ').capitalize}", :white
1639
+ say "📦 Package: #{package_name}", :white
1640
+
1641
+ highest_version_code = fetch_highest_version_code(package_name)
1642
+ version_code = local_version_code
1643
+
1644
+ if highest_version_code && local_version_code <= highest_version_code
1645
+ version_code = highest_version_code + 1
1646
+ say "🔢 Version: #{version_name} (#{local_version_code} → #{version_code})", :white
1647
+ say " ↳ Auto-incremented (#{highest_version_code} already on Play Store)", :yellow
1648
+ else
1649
+ say "🔢 Version: #{version_name} (#{version_code})", :white
1650
+ end
1651
+ else
1652
+ # Expo - already printed above, just show if no increment was needed
1653
+ unless needs_increment
1654
+ say "🔧 Framework: Expo (React Native)", :white
1655
+ say "📦 Package: #{package_name}", :white
1656
+ say "🔢 Version: #{version_name} (#{version_code})", :white
1657
+ end
1658
+ end
1659
+ say ""
1660
+
1661
+ # Try to get keystore from MySigner
1662
+ keystore_info = fetch_keystore_for_build(package_name)
1663
+ if keystore_info
1664
+ say "🔐 Keystore: #{keystore_info[:name]}", :green
1665
+ else
1666
+ say "⚠️ No keystore configured - will use debug signing", :yellow
1667
+ say " Run 'mysigner android init' to set up release signing", :yellow
1668
+ end
1669
+ say ""
1670
+ say "⏱️ This may take a few minutes...", :yellow
1671
+ say ""
1672
+
1673
+ # Build based on framework (pass version_code override if incremented)
1674
+ # For Expo, we already regenerated with correct version, so no override needed
1675
+ version_code_override = nil
1676
+ unless is_expo
1677
+ version_code_override = (version_code != local_version_code) ? version_code : nil
1678
+ end
1679
+
1680
+ aab_path = case framework
1681
+ when :flutter
1682
+ build_flutter_aab(project_dir, keystore_info, version_code_override)
1683
+ when :maui, :xamarin, :xamarin_forms
1684
+ build_dotnet_aab(project_dir, project_info[:csproj_path], framework, keystore_info, version_code_override)
1685
+ when :react_native, :capacitor, :native
1686
+ build_gradle_aab(android_dir, framework, keystore_info, version_code_override)
1687
+ else
1688
+ build_gradle_aab(android_dir, framework, keystore_info, version_code_override)
1689
+ end
1690
+
1691
+ unless aab_path && File.exist?(aab_path)
1692
+ error "AAB file not found after build"
1693
+ say "Check build output for errors.", :yellow
1694
+ exit 1
1695
+ end
1696
+
1697
+ say ""
1698
+ say "=" * 80, :green
1699
+ say "✓ Build complete!", :green
1700
+ say "=" * 80, :green
1701
+ say ""
1702
+ say "📦 AAB: #{aab_path}", :cyan
1703
+ say "📊 Size: #{format_bytes(File.size(aab_path))}", :cyan
1704
+ say ""
1705
+ say "Next step:", :bold
1706
+ say " Upload this AAB to Google Play Console → Internal testing → Create release", :white
1707
+ say ""
1708
+ say "After uploading, you can use:", :cyan
1709
+ say " mysigner ship internal --platform android", :green
1710
+ say ""
1711
+
1712
+ # Open the folder containing the AAB
1713
+ aab_dir = File.dirname(aab_path)
1714
+ say "📂 Opening folder...", :yellow
1715
+ if RUBY_PLATFORM =~ /darwin/
1716
+ system('open', aab_dir)
1717
+ elsif RUBY_PLATFORM =~ /linux/
1718
+ system('xdg-open', aab_dir)
1719
+ elsif RUBY_PLATFORM =~ /mingw|mswin/
1720
+ system('explorer', aab_dir.gsub('/', '\\'))
1721
+ end
1722
+
1723
+ rescue Build::Detector::NoProjectError => e
1724
+ error e.message
1725
+ say ""
1726
+ say "Run this command from an Android project directory.", :yellow
1727
+ exit 1
1728
+ rescue => e
1729
+ error "Build failed: #{e.message}"
1730
+ exit 1
1731
+ end
1732
+ end
1733
+
1734
+ def build_flutter_aab(project_dir, keystore_info = nil, version_code_override = nil)
1735
+ # Check for flutter
1736
+ unless system('which flutter > /dev/null 2>&1')
1737
+ error "Flutter not found in PATH"
1738
+ say "Install Flutter: https://flutter.dev/docs/get-started/install", :yellow
1739
+ exit 1
1740
+ end
1741
+
1742
+ Dir.chdir(project_dir) do
1743
+ args = ['flutter', 'build', 'appbundle', '--release']
1744
+
1745
+ # Add version code override
1746
+ if version_code_override
1747
+ args += ['--build-number', version_code_override.to_s]
1748
+ end
1749
+
1750
+ # Add signing if keystore provided (Flutter reads key.properties from android/)
1751
+ if keystore_info
1752
+ # Create key.properties for Flutter
1753
+ key_props = File.join(project_dir, 'android/key.properties')
1754
+ File.write(key_props, <<~PROPS)
1755
+ storePassword=#{keystore_info[:password]}
1756
+ keyPassword=#{keystore_info[:key_password]}
1757
+ keyAlias=#{keystore_info[:key_alias]}
1758
+ storeFile=#{keystore_info[:path]}
1759
+ PROPS
1760
+ end
1761
+
1762
+ success = system(*args)
1763
+ unless success
1764
+ error "Flutter build failed"
1765
+ exit 1
1766
+ end
1767
+ end
1768
+
1769
+ # Flutter outputs to build/app/outputs/bundle/release/
1770
+ aab_path = File.join(project_dir, 'build/app/outputs/bundle/release/app-release.aab')
1771
+ unless File.exist?(aab_path)
1772
+ # Try alternate paths
1773
+ alt_paths = Dir.glob(File.join(project_dir, 'build/app/outputs/bundle/*/*.aab'))
1774
+ aab_path = alt_paths.first if alt_paths.any?
1775
+ end
1776
+ aab_path
1777
+ end
1778
+
1779
+ def expo_project?(directory)
1780
+ # Check for Expo markers
1781
+ (File.exist?(File.join(directory, 'app.json')) || File.exist?(File.join(directory, 'app.config.js'))) &&
1782
+ File.exist?(File.join(directory, 'package.json')) &&
1783
+ File.read(File.join(directory, 'package.json')).include?('expo')
1784
+ end
1785
+
1786
+ def regenerate_expo_android(project_dir, new_version_code)
1787
+ app_json_path = File.join(project_dir, 'app.json')
1788
+ android_dir = File.join(project_dir, 'android')
1789
+ local_props_path = File.join(android_dir, 'local.properties')
1790
+
1791
+ # Preserve local.properties if it exists
1792
+ local_props_content = File.read(local_props_path) if File.exist?(local_props_path)
1793
+
1794
+ # Read original app.json
1795
+ original_content = File.read(app_json_path)
1796
+ config = JSON.parse(original_content)
1797
+
1798
+ # Set the new versionCode
1799
+ config['expo'] ||= {}
1800
+ config['expo']['android'] ||= {}
1801
+ config['expo']['android']['versionCode'] = new_version_code
1802
+
1803
+ # Write modified app.json
1804
+ File.write(app_json_path, JSON.pretty_generate(config))
1805
+
1806
+ begin
1807
+ # Delete existing android folder
1808
+ if Dir.exist?(android_dir)
1809
+ FileUtils.rm_rf(android_dir)
1810
+ end
1811
+
1812
+ # Run expo prebuild
1813
+ Dir.chdir(project_dir) do
1814
+ success = system('npx', 'expo', 'prebuild', '--platform', 'android', '--clean')
1815
+ unless success
1816
+ raise "expo prebuild failed"
1817
+ end
1818
+ end
1819
+
1820
+ # Restore local.properties if we had one, or create default
1821
+ if local_props_content
1822
+ File.write(local_props_path, local_props_content)
1823
+ else
1824
+ # Try to detect Android SDK and create local.properties
1825
+ sdk_path = ENV['ANDROID_HOME'] || ENV['ANDROID_SDK_ROOT'] ||
1826
+ File.expand_path('~/Library/Android/sdk')
1827
+ if Dir.exist?(sdk_path)
1828
+ File.write(local_props_path, "sdk.dir=#{sdk_path}\n")
1829
+ end
1830
+ end
1831
+ ensure
1832
+ # Restore original app.json
1833
+ File.write(app_json_path, original_content)
1834
+ end
1835
+ end
1836
+
1837
+ def fetch_highest_version_code(package_name)
1838
+ config = Mysigner::Config.new
1839
+ return nil unless config.exists?
1840
+ config.load
1841
+ return nil unless config.api_token && config.organization_id
1842
+
1843
+ client = Mysigner::Client.new(api_url: config.api_url, api_token: config.api_token)
1844
+
1845
+ # Find app by package name
1846
+ response = client.get("/api/v1/organizations/#{config.organization_id}/android_apps")
1847
+ apps = response[:data]['android_apps'] || []
1848
+ app = apps.find { |a| a['package_name'] == package_name }
1849
+
1850
+ return app['highest_version_code'].to_i if app && app['highest_version_code']
1851
+ nil
1852
+ rescue => e
1853
+ # Silently fail - we'll use local version
1854
+ nil
1855
+ end
1856
+
1857
+ def fetch_keystore_for_build(package_name)
1858
+ config = Mysigner::Config.new
1859
+ return nil unless config.exists?
1860
+ config.load
1861
+ return nil unless config.api_token && config.organization_id
1862
+
1863
+ client = Mysigner::Client.new(api_url: config.api_url, api_token: config.api_token)
1864
+ keystore_manager = Signing::KeystoreManager.new(client, config.organization_id)
1865
+
1866
+ # Find app by package name to get its keystore
1867
+ response = client.get("/api/v1/organizations/#{config.organization_id}/android_apps")
1868
+ apps = response[:data]['android_apps'] || []
1869
+ app = apps.find { |a| a['package_name'] == package_name }
1870
+
1871
+ if app
1872
+ # Get active keystore for this app with secrets
1873
+ keystore = keystore_manager.active_keystore(android_app_id: app['id'], include_secrets: true)
1874
+ if keystore
1875
+ # Download the keystore file
1876
+ downloaded = keystore_manager.get_or_download(keystore['id'])
1877
+ return {
1878
+ path: downloaded[:path],
1879
+ name: keystore['name'],
1880
+ password: keystore['keystore_password'],
1881
+ key_alias: keystore['key_alias'],
1882
+ key_password: keystore['key_password'] || keystore['keystore_password']
1883
+ }
1884
+ end
1885
+ end
1886
+
1887
+ # Try to get any active keystore
1888
+ keystore = keystore_manager.active_keystore(include_secrets: true)
1889
+ if keystore
1890
+ downloaded = keystore_manager.get_or_download(keystore['id'])
1891
+ return {
1892
+ path: downloaded[:path],
1893
+ name: keystore['name'],
1894
+ password: keystore['keystore_password'],
1895
+ key_alias: keystore['key_alias'],
1896
+ key_password: keystore['key_password'] || keystore['keystore_password']
1897
+ }
1898
+ end
1899
+
1900
+ nil
1901
+ rescue => e
1902
+ # Silently fail - we'll use debug signing
1903
+ nil
1904
+ end
1905
+
1906
+ def build_dotnet_aab(project_dir, csproj_path, framework, keystore_info = nil, version_code_override = nil)
1907
+ # Check for dotnet
1908
+ unless system('which dotnet > /dev/null 2>&1')
1909
+ error ".NET SDK not found in PATH"
1910
+ say "Install .NET: https://dotnet.microsoft.com/download", :yellow
1911
+ exit 1
1912
+ end
1913
+
1914
+ Dir.chdir(project_dir) do
1915
+ base_args = []
1916
+
1917
+ # Add signing args if keystore provided
1918
+ if keystore_info
1919
+ base_args += [
1920
+ "-p:AndroidKeyStore=true",
1921
+ "-p:AndroidSigningKeyStore=#{keystore_info[:path]}",
1922
+ "-p:AndroidSigningKeyAlias=#{keystore_info[:key_alias]}",
1923
+ "-p:AndroidSigningKeyPass=#{keystore_info[:key_password]}",
1924
+ "-p:AndroidSigningStorePass=#{keystore_info[:password]}"
1925
+ ]
1926
+ end
1927
+
1928
+ # Add version code override
1929
+ if version_code_override
1930
+ base_args << "-p:ApplicationVersion=#{version_code_override}"
1931
+ end
1932
+
1933
+ # MAUI uses dotnet publish with Android target
1934
+ if framework == :maui
1935
+ success = system(
1936
+ 'dotnet', 'publish',
1937
+ '-f', 'net8.0-android',
1938
+ '-c', 'Release',
1939
+ '-p:AndroidPackageFormat=aab',
1940
+ *base_args
1941
+ )
1942
+ else
1943
+ # Xamarin uses msbuild
1944
+ success = system(
1945
+ 'dotnet', 'build',
1946
+ '-c', 'Release',
1947
+ '-p:AndroidPackageFormat=aab',
1948
+ *base_args
1949
+ )
1950
+ end
1951
+
1952
+ unless success
1953
+ error ".NET build failed"
1954
+ exit 1
1955
+ end
1956
+ end
1957
+
1958
+ # Find the AAB - MAUI outputs to bin/Release/net8.0-android/publish/
1959
+ aab_paths = Dir.glob(File.join(project_dir, '**/*.aab'))
1960
+ aab_paths.reject! { |p| p.include?('/obj/') } # Exclude obj folder
1961
+ aab_paths.max_by { |f| File.mtime(f) } # Return most recent
1962
+ end
1963
+
1964
+ def build_gradle_aab(android_dir, framework, keystore_info = nil, version_code_override = nil)
1965
+ # Check for gradlew
1966
+ gradlew_path = File.join(android_dir, 'gradlew')
1967
+ unless File.exist?(gradlew_path)
1968
+ error "Gradle wrapper not found at #{gradlew_path}"
1969
+ case framework
1970
+ when :react_native
1971
+ say "Run 'npx expo prebuild' or ensure android/ folder is set up.", :yellow
1972
+ when :capacitor
1973
+ say "Run 'npx cap sync android' first.", :yellow
1974
+ else
1975
+ say "Ensure the android/ folder has gradlew.", :yellow
1976
+ end
1977
+ exit 1
1978
+ end
1979
+
1980
+ # Build gradle command with signing via command-line properties
1981
+ gradle_args = ['./gradlew', 'bundleRelease', '--warning-mode=all']
1982
+
1983
+ if keystore_info
1984
+ # Pass signing config via command-line properties (no file modification needed)
1985
+ gradle_args += [
1986
+ "-Pandroid.injected.signing.store.file=#{keystore_info[:path]}",
1987
+ "-Pandroid.injected.signing.store.password=#{keystore_info[:password]}",
1988
+ "-Pandroid.injected.signing.key.alias=#{keystore_info[:key_alias]}",
1989
+ "-Pandroid.injected.signing.key.password=#{keystore_info[:key_password]}"
1990
+ ]
1991
+ end
1992
+
1993
+ # Pass version code override if provided (no file modification needed)
1994
+ if version_code_override
1995
+ gradle_args << "-PversionCode=#{version_code_override}"
1996
+ end
1997
+
1998
+ Dir.chdir(android_dir) do
1999
+ success = system(*gradle_args)
2000
+ unless success
2001
+ error "Gradle build failed"
2002
+ exit 1
2003
+ end
2004
+ end
2005
+
2006
+ # Find the AAB
2007
+ aab_path = File.join(android_dir, 'app/build/outputs/bundle/release/app-release.aab')
2008
+ unless File.exist?(aab_path)
2009
+ # Try alternate paths
2010
+ alt_paths = Dir.glob(File.join(android_dir, 'app/build/outputs/bundle/*/*.aab'))
2011
+ aab_path = alt_paths.first if alt_paths.any?
2012
+ end
2013
+ aab_path
2014
+ end
2015
+
2016
+ def parse_expo_config(directory)
2017
+ # Try app.json first
2018
+ app_json_path = File.join(directory, 'app.json')
2019
+ if File.exist?(app_json_path)
2020
+ begin
2021
+ config = JSON.parse(File.read(app_json_path))
2022
+ expo = config['expo'] || config
2023
+ return {
2024
+ package_name: expo.dig('android', 'package'),
2025
+ bundle_id: expo.dig('ios', 'bundleIdentifier'),
2026
+ name: expo['name'],
2027
+ version: expo['version'],
2028
+ version_code: expo.dig('android', 'versionCode')
2029
+ }
2030
+ rescue JSON::ParserError
2031
+ # Invalid JSON, ignore
2032
+ end
2033
+ end
2034
+
2035
+ # Try app.config.js (basic extraction)
2036
+ app_config_path = File.join(directory, 'app.config.js')
2037
+ if File.exist?(app_config_path)
2038
+ content = File.read(app_config_path)
2039
+ # Basic regex extraction for package name
2040
+ if content =~ /android\s*:\s*\{[^}]*package\s*:\s*["']([^"']+)["']/m
2041
+ package_name = $1
2042
+ name = nil
2043
+ if content =~ /name\s*:\s*["']([^"']+)["']/
2044
+ name = $1
2045
+ end
2046
+ return {
2047
+ package_name: package_name,
2048
+ bundle_id: nil,
2049
+ name: name
2050
+ }
2051
+ end
2052
+ end
2053
+
2054
+ nil
2055
+ end
2056
+
2057
+ public
2058
+
2059
+ # ==================== BUNDLE IDS ====================
2060
+
2061
+ desc "bundleid SUBCOMMAND", "Register and manage iOS Bundle IDs"
2062
+ long_desc <<~DESC
2063
+ Register and manage iOS Bundle IDs in App Store Connect.
2064
+
2065
+ WHAT ARE BUNDLE IDs?
2066
+
2067
+ Bundle IDs are unique identifiers for your iOS apps (e.g., com.company.app).
2068
+ Every iOS app and app extension needs its own Bundle ID registered in
2069
+ App Store Connect before you can sign and distribute it.
2070
+
2071
+ SUBCOMMANDS:
2072
+
2073
+ mysigner bundleid register IDENTIFIER [NAME]
2074
+ Register a new Bundle ID in App Store Connect.
2075
+ The NAME is optional - defaults to the last part of the identifier.
2076
+
2077
+ mysigner bundleid list
2078
+ List all registered Bundle IDs in your organization.
2079
+
2080
+ EXAMPLES:
2081
+
2082
+ # Register main app bundle ID
2083
+ mysigner bundleid register com.company.myapp
2084
+
2085
+ # Register with a custom name
2086
+ mysigner bundleid register com.company.myapp "My App"
2087
+
2088
+ # Register a widget extension bundle ID
2089
+ mysigner bundleid register com.company.myapp.widget "My App Widget"
2090
+
2091
+ # List all bundle IDs
2092
+ mysigner bundleid list
2093
+
2094
+ NOTES:
2095
+
2096
+ • Bundle IDs must be unique across all of App Store Connect
2097
+ • Use reverse domain notation (e.g., com.company.app)
2098
+ • Extensions should use parent app's bundle ID as prefix (e.g., com.company.app.widget)
2099
+ • After registering, run 'mysigner sync ios' to update local cache
2100
+ DESC
2101
+ def bundleid(action, *args)
2102
+ config = load_config
2103
+ client = create_client(config)
2104
+
2105
+ case action
2106
+ when 'register'
2107
+ if args.empty?
2108
+ error "Usage: mysigner bundleid register IDENTIFIER [NAME]"
2109
+ say ""
2110
+ say "Example: mysigner bundleid register com.company.myapp", :yellow
2111
+ say "Example: mysigner bundleid register com.company.myapp.widget \"My Widget\"", :yellow
2112
+ exit 1
2113
+ end
2114
+
2115
+ identifier = args[0]
2116
+ # Default name is the last component of the identifier
2117
+ name = args[1] || identifier.split('.').last.capitalize
2118
+
2119
+ # Validate bundle ID format
2120
+ unless identifier =~ /^[a-zA-Z][a-zA-Z0-9.-]*\.[a-zA-Z][a-zA-Z0-9.-]*$/
2121
+ error "Invalid Bundle ID format: #{identifier}"
2122
+ say ""
2123
+ say "Bundle IDs must:", :yellow
2124
+ say " • Start with a letter", :cyan
2125
+ say " • Use reverse domain notation (e.g., com.company.app)", :cyan
2126
+ say " • Contain only letters, numbers, hyphens, and periods", :cyan
2127
+ exit 1
2128
+ end
2129
+
2130
+ say "🔗 Registering Bundle ID...", :cyan
2131
+ say ""
2132
+ say " Identifier: #{identifier}", :white
2133
+ say " Name: #{name}", :white
2134
+ say ""
2135
+
2136
+ begin
2137
+ response = client.post(
2138
+ "/api/v1/organizations/#{config.current_organization_id}/bundle_ids",
2139
+ body: {
2140
+ identifier: identifier,
2141
+ name: name,
2142
+ platform: 'IOS'
2143
+ }
2144
+ )
2145
+
2146
+ bundle_id_data = response[:data]['bundle_id'] || response[:data]
2147
+ say "✓ Bundle ID registered successfully!", :green
2148
+ say ""
2149
+ say "Details:", :bold
2150
+ say " Identifier: #{bundle_id_data['identifier'] || identifier}"
2151
+ say " Name: #{bundle_id_data['name'] || name}"
2152
+ say ""
2153
+ say "Next steps:", :cyan
2154
+ say " 1. Sync to update local cache: mysigner sync ios", :white
2155
+ say " 2. Create a provisioning profile: mysigner doctor (will auto-create)", :white
2156
+ say " 3. Or run: mysigner signing configure", :white
2157
+ rescue Mysigner::ValidationError => e
2158
+ error "Validation failed:"
2159
+ if e.details
2160
+ e.details.each do |field, errors|
2161
+ say " #{field}: #{errors.join(', ')}", :red
2162
+ end
2163
+ else
2164
+ say " #{e.message}", :red
2165
+ end
2166
+ exit 1
2167
+ rescue Mysigner::ClientError => e
2168
+ if e.message.include?("already exists") || e.message.include?("ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE")
2169
+ say "ℹ️ Bundle ID already registered: #{identifier}", :yellow
2170
+ say ""
2171
+ say "This Bundle ID already exists in App Store Connect.", :white
2172
+ say "Run 'mysigner sync ios' to update your local cache.", :cyan
2173
+ else
2174
+ error "Failed to register Bundle ID: #{e.message}"
2175
+ say ""
2176
+ say "Common issues:", :yellow
2177
+ say " • Bundle ID already exists (check App Store Connect)", :cyan
2178
+ say " • Invalid format (must be like com.company.app)", :cyan
2179
+ say " • API credentials may not have permission", :cyan
2180
+ end
2181
+ exit 1
2182
+ end
2183
+
2184
+ when 'list'
2185
+ say "📦 Registered Bundle IDs", :cyan
2186
+ say ""
2187
+
2188
+ begin
2189
+ response = client.get(
2190
+ "/api/v1/organizations/#{config.current_organization_id}/bundle_ids"
2191
+ )
2192
+ bundle_ids = response[:data]['bundle_ids'] || response[:data] || []
2193
+
2194
+ if bundle_ids.empty?
2195
+ say " No Bundle IDs found", :yellow
2196
+ say ""
2197
+ say " Register one with: mysigner bundleid register com.company.app", :cyan
2198
+ else
2199
+ bundle_ids.each do |bid|
2200
+ identifier = bid['identifier'] || bid['bundle_id']
2201
+ name = bid['name'] || 'N/A'
2202
+ say " • #{name}", :green
2203
+ say " Identifier: #{identifier}"
2204
+ say ""
2205
+ end
2206
+ end
2207
+ rescue Mysigner::ClientError => e
2208
+ error "Failed to list Bundle IDs: #{e.message}"
2209
+ exit 1
2210
+ end
2211
+
2212
+ else
2213
+ error "Unknown action: #{action}"
2214
+ say ""
2215
+ say "Available actions:", :yellow
2216
+ say " mysigner bundleid register IDENTIFIER [NAME]", :cyan
2217
+ say " mysigner bundleid list", :cyan
2218
+ exit 1
2219
+ end
2220
+ end
2221
+
2222
+ # ==================== APPS (iOS + Android) ====================
2223
+
2224
+ desc "apps", "List apps from App Store Connect and/or Google Play"
2225
+ long_desc <<~DESC
2226
+ List apps synced from app stores.
2227
+
2228
+ PLATFORMS:
2229
+ --platform ios List iOS apps only
2230
+ --platform android List Android apps only
2231
+ (default) List apps from both platforms
2232
+
2233
+ EXAMPLES:
2234
+ mysigner apps # List all apps
2235
+ mysigner apps --platform ios # iOS apps only
2236
+ mysigner apps --platform android # Android apps only
2237
+ mysigner apps -q "myapp" # Search by name
2238
+ DESC
2239
+ method_option :platform, type: :string, desc: 'Filter by platform: ios, android'
2240
+ method_option :search, type: :string, aliases: '-q', desc: 'Search by name or identifier'
2241
+ method_option :page, type: :numeric, default: 1, desc: 'Page number'
2242
+ method_option :per_page, type: :numeric, default: 50, desc: 'Apps per page'
2243
+ def apps
2244
+ config = load_config
2245
+ client = create_client(config)
2246
+
2247
+ platform = options[:platform]&.downcase
2248
+ show_ios = platform.nil? || platform == 'ios'
2249
+ show_android = platform.nil? || platform == 'android'
2250
+
2251
+ params = {
2252
+ page: options[:page],
2253
+ per_page: options[:per_page]
2254
+ }
2255
+ params[:q] = options[:search] if options[:search]
2256
+
2257
+ # iOS Apps
2258
+ if show_ios
2259
+ say "📱 iOS Apps", :cyan
2260
+ say ""
2261
+
2262
+ begin
2263
+ response = client.get(
2264
+ "/api/v1/organizations/#{config.current_organization_id}/apple_apps",
2265
+ params: params
2266
+ )
2267
+ ios_apps = response[:data]['data']&.fetch('apps', nil) || []
2268
+
2269
+ if ios_apps.empty?
2270
+ say " No iOS apps found", :yellow
2271
+ say ""
2272
+ say " Why don't my iOS apps appear?", :cyan
2273
+ say " ─────────────────────────────", :cyan
2274
+ say ""
2275
+ say " Common reasons:", :yellow
2276
+ say " • No apps registered in App Store Connect yet"
2277
+ say " • Team ID not set on your credential"
2278
+ say " • Bundle IDs exist but apps not created in App Store Connect"
2279
+ say ""
2280
+ say " How to register an iOS app:", :cyan
2281
+ say ""
2282
+ say " 1. Register a Bundle ID"
2283
+ say " https://developer.apple.com/account/resources/identifiers/list"
2284
+ say " Click '+' → App IDs → Enter your Bundle ID (e.g., com.company.appname)"
2285
+ say ""
2286
+ say " 2. Create the app in App Store Connect"
2287
+ say " https://appstoreconnect.apple.com/apps"
2288
+ say " My Apps → '+' → New App → Select your Bundle ID"
2289
+ say ""
2290
+ say " 3. Sync your organization"
2291
+ say " Run: ", :white
2292
+ say "mysigner sync ios", :green
2293
+ say ""
2294
+ say " 💡 Team ID tip:", :yellow
2295
+ say " Apple's API doesn't expose Team ID. Set it manually in the web dashboard."
2296
+ say " Find yours at: https://developer.apple.com/account/#!/membership/"
2297
+ say ""
2298
+ else
2299
+ ios_apps.each do |app|
2300
+ say " • #{app['name'] || app['bundle_id']}", :green
2301
+ say " Bundle ID: #{app['bundle_id']}"
2302
+ say ""
2303
+ end
2304
+ end
2305
+ rescue Mysigner::ClientError => e
2306
+ say " Could not fetch iOS apps: #{e.message}", :yellow
2307
+ end
2308
+ say ""
2309
+ end
2310
+
2311
+ # Android Apps
2312
+ if show_android
2313
+ say "🤖 Android Apps", :cyan
2314
+ say ""
2315
+
2316
+ begin
2317
+ response = client.get(
2318
+ "/api/v1/organizations/#{config.current_organization_id}/android_apps",
2319
+ params: params
2320
+ )
2321
+ android_apps = response[:data]['android_apps'] || []
2322
+
2323
+ if android_apps.empty?
2324
+ say " No Android apps found", :yellow
2325
+ say " Sync with: mysigner sync android", :yellow
2326
+ else
2327
+ android_apps.each do |app|
2328
+ say " • #{app['name'] || app['package_name']}", :green
2329
+ say " Package: #{app['package_name']}"
2330
+ say ""
2331
+ end
2332
+ end
2333
+ rescue Mysigner::ClientError => e
2334
+ say " Could not fetch Android apps: #{e.message}", :yellow
2335
+ end
2336
+ end
2337
+ end
2338
+
2339
+ # ==================== MERCHANT IDS (Apple Pay) ====================
2340
+
2341
+ desc "merchant-ids", "List Apple Pay Merchant IDs"
2342
+ method_option :search, type: :string, aliases: '-q', desc: 'Search by identifier or name'
2343
+ method_option :page, type: :numeric, default: 1, desc: 'Page number'
2344
+ method_option :per_page, type: :numeric, default: 50, desc: 'Items per page'
2345
+ def merchant_ids
2346
+ config = load_config
2347
+ client = create_client(config)
2348
+
2349
+ say "💳 Merchant IDs", :cyan
2350
+ say ""
2351
+
2352
+ params = {
2353
+ page: options[:page],
2354
+ per_page: options[:per_page]
2355
+ }
2356
+ params[:q] = options[:search] if options[:search]
2357
+
2358
+ begin
2359
+ response = client.get(
2360
+ "/api/v1/organizations/#{config.current_organization_id}/merchant_ids",
2361
+ params: params
2362
+ )
2363
+ merchant_ids = response[:data]['merchant_ids'] || []
2364
+ pagination = response[:data]['pagination']
2365
+
2366
+ if merchant_ids.empty?
2367
+ say " No Merchant IDs found", :yellow
2368
+ say ""
2369
+ say " Create one with: mysigner merchant-id create IDENTIFIER", :cyan
2370
+ else
2371
+ merchant_ids.each do |m|
2372
+ say " • #{m['identifier']}", :green
2373
+ say " Name: #{m['name']}" if m['name'] && m['name'] != m['identifier']
2374
+ say " Team: #{m['team_id']}" if m['team_id']
2375
+ say ""
2376
+ end
2377
+
2378
+ if pagination
2379
+ say "Page #{pagination['page']} of #{pagination['total_pages']} (#{pagination['total']} total)", :yellow
2380
+ end
2381
+ end
2382
+ rescue Mysigner::ClientError => e
2383
+ error "Failed to fetch Merchant IDs: #{e.message}"
2384
+ exit 1
2385
+ end
2386
+ end
2387
+
2388
+ desc "merchant-id SUBCOMMAND", "Manage Apple Pay Merchant IDs"
2389
+ long_desc <<~DESC
2390
+ Create and delete Apple Pay Merchant IDs.
2391
+
2392
+ SUBCOMMANDS:
2393
+
2394
+ mysigner merchant-id create IDENTIFIER [--name NAME]
2395
+ Create a new Merchant ID in App Store Connect
2396
+
2397
+ mysigner merchant-id delete IDENTIFIER
2398
+ Delete a Merchant ID from App Store Connect
2399
+
2400
+ EXAMPLES:
2401
+
2402
+ mysigner merchant-id create merchant.com.company.app
2403
+ mysigner merchant-id create merchant.com.company.app --name "My Payment"
2404
+ mysigner merchant-id delete merchant.com.company.app
2405
+ DESC
2406
+ method_option :name, type: :string, aliases: '-n', desc: 'Friendly name for the Merchant ID'
2407
+ def merchant_id(action, identifier = nil)
2408
+ config = load_config
2409
+ client = create_client(config)
2410
+
2411
+ case action
2412
+ when 'create'
2413
+ if identifier.nil?
2414
+ error "Usage: mysigner merchant-id create IDENTIFIER [--name NAME]"
2415
+ say ""
2416
+ say "Example: mysigner merchant-id create merchant.com.company.app", :yellow
2417
+ exit 1
2418
+ end
2419
+
2420
+ unless identifier.start_with?('merchant.')
2421
+ error "Merchant ID must start with 'merchant.'"
2422
+ say ""
2423
+ say "Example: merchant.com.company.app", :cyan
2424
+ exit 1
2425
+ end
2426
+
2427
+ say "💳 Creating Merchant ID...", :cyan
2428
+ say ""
2429
+
2430
+ begin
2431
+ response = client.post(
2432
+ "/api/v1/organizations/#{config.current_organization_id}/merchant_ids",
2433
+ body: {
2434
+ identifier: identifier,
2435
+ name: options[:name] || identifier
2436
+ }
2437
+ )
2438
+
2439
+ m = response[:data]['merchant_id'] || response[:data]
2440
+ say "✓ Merchant ID created successfully!", :green
2441
+ say ""
2442
+ say " Identifier: #{m['identifier'] || identifier}", :white
2443
+ say " Name: #{m['name']}", :white if m['name']
2444
+ rescue Mysigner::ClientError => e
2445
+ if e.message.include?("already exists")
2446
+ say "ℹ️ Merchant ID already exists: #{identifier}", :yellow
2447
+ else
2448
+ error "Failed to create Merchant ID: #{e.message}"
2449
+ end
2450
+ exit 1
2451
+ end
2452
+
2453
+ when 'delete'
2454
+ if identifier.nil?
2455
+ error "Usage: mysigner merchant-id delete IDENTIFIER"
2456
+ exit 1
2457
+ end
2458
+
2459
+ say "💳 Deleting Merchant ID...", :cyan
2460
+ say ""
2461
+
2462
+ begin
2463
+ # First find the merchant ID by identifier
2464
+ response = client.get(
2465
+ "/api/v1/organizations/#{config.current_organization_id}/merchant_ids",
2466
+ params: { q: identifier }
2467
+ )
2468
+ merchant_ids = response[:data]['merchant_ids'] || []
2469
+ m = merchant_ids.find { |x| x['identifier'] == identifier }
2470
+
2471
+ if m.nil?
2472
+ error "Merchant ID not found: #{identifier}"
2473
+ exit 1
2474
+ end
2475
+
2476
+ client.delete(
2477
+ "/api/v1/organizations/#{config.current_organization_id}/merchant_ids/#{m['id']}"
2478
+ )
2479
+
2480
+ say "✓ Merchant ID deleted: #{identifier}", :green
2481
+ rescue Mysigner::ClientError => e
2482
+ error "Failed to delete Merchant ID: #{e.message}"
2483
+ exit 1
2484
+ end
2485
+
2486
+ else
2487
+ error "Unknown action: #{action}"
2488
+ say ""
2489
+ say "Available actions:", :yellow
2490
+ say " mysigner merchant-id create IDENTIFIER [--name NAME]", :cyan
2491
+ say " mysigner merchant-id delete IDENTIFIER", :cyan
2492
+ exit 1
2493
+ end
2494
+ end
2495
+
2496
+ # ==================== APP GROUPS ====================
2497
+
2498
+ desc "app-groups", "List App Groups"
2499
+ method_option :search, type: :string, aliases: '-q', desc: 'Search by identifier or name'
2500
+ method_option :page, type: :numeric, default: 1, desc: 'Page number'
2501
+ method_option :per_page, type: :numeric, default: 50, desc: 'Items per page'
2502
+ def app_groups
2503
+ config = load_config
2504
+ client = create_client(config)
2505
+
2506
+ say "📦 App Groups", :cyan
2507
+ say ""
2508
+
2509
+ params = {
2510
+ page: options[:page],
2511
+ per_page: options[:per_page]
2512
+ }
2513
+ params[:q] = options[:search] if options[:search]
2514
+
2515
+ begin
2516
+ response = client.get(
2517
+ "/api/v1/organizations/#{config.current_organization_id}/app_groups",
2518
+ params: params
2519
+ )
2520
+ app_groups = response[:data]['app_groups'] || []
2521
+ pagination = response[:data]['pagination']
2522
+
2523
+ if app_groups.empty?
2524
+ say " No App Groups found", :yellow
2525
+ say ""
2526
+ say " Register one with: mysigner app-group register IDENTIFIER", :cyan
2527
+ say ""
2528
+ say " Note: App Groups must first be created in Apple Developer Portal", :yellow
2529
+ else
2530
+ app_groups.each do |g|
2531
+ say " • #{g['identifier']}", :green
2532
+ say " Name: #{g['name']}" if g['name'] && g['name'] != g['identifier']
2533
+ say " Team: #{g['team_id']}" if g['team_id']
2534
+ say ""
2535
+ end
2536
+
2537
+ if pagination
2538
+ say "Page #{pagination['page']} of #{pagination['total_pages']} (#{pagination['total']} total)", :yellow
2539
+ end
2540
+ end
2541
+ rescue Mysigner::ClientError => e
2542
+ error "Failed to fetch App Groups: #{e.message}"
2543
+ exit 1
2544
+ end
2545
+ end
2546
+
2547
+ desc "app-group SUBCOMMAND", "Manage App Groups"
2548
+ long_desc <<~DESC
2549
+ Register and delete App Groups.
2550
+
2551
+ IMPORTANT: Apple does NOT provide a public API to create App Groups.
2552
+ You must first create them in the Apple Developer Portal, then register
2553
+ them here to track and associate with Bundle IDs.
2554
+
2555
+ SUBCOMMANDS:
2556
+
2557
+ mysigner app-group register IDENTIFIER [--name NAME]
2558
+ Register an existing App Group from Apple Developer Portal
2559
+
2560
+ mysigner app-group delete IDENTIFIER
2561
+ Remove an App Group from My Signer (does not delete from Apple)
2562
+
2563
+ EXAMPLES:
2564
+
2565
+ mysigner app-group register group.com.company.shared
2566
+ mysigner app-group register group.com.company.shared --name "Shared Data"
2567
+ mysigner app-group delete group.com.company.shared
2568
+ DESC
2569
+ method_option :name, type: :string, aliases: '-n', desc: 'Friendly name for the App Group'
2570
+ def app_group(action, identifier = nil)
2571
+ config = load_config
2572
+ client = create_client(config)
2573
+
2574
+ case action
2575
+ when 'register'
2576
+ if identifier.nil?
2577
+ error "Usage: mysigner app-group register IDENTIFIER [--name NAME]"
2578
+ say ""
2579
+ say "Example: mysigner app-group register group.com.company.shared", :yellow
2580
+ say ""
2581
+ say "Note: Create the App Group in Apple Developer Portal first!", :cyan
2582
+ exit 1
2583
+ end
2584
+
2585
+ unless identifier.start_with?('group.')
2586
+ error "App Group identifier must start with 'group.'"
2587
+ say ""
2588
+ say "Example: group.com.company.shared", :cyan
2589
+ exit 1
2590
+ end
2591
+
2592
+ say "📦 Registering App Group...", :cyan
2593
+ say ""
2594
+
2595
+ begin
2596
+ response = client.post(
2597
+ "/api/v1/organizations/#{config.current_organization_id}/app_groups",
2598
+ body: {
2599
+ identifier: identifier,
2600
+ name: options[:name] || identifier
2601
+ }
2602
+ )
2603
+
2604
+ g = response[:data]['app_group'] || response[:data]
2605
+ say "✓ App Group registered!", :green
2606
+ say ""
2607
+ say " Identifier: #{g['identifier'] || identifier}", :white
2608
+ say " Name: #{g['name']}", :white if g['name']
2609
+ say ""
2610
+ say " Remember: This only registers the App Group in My Signer.", :yellow
2611
+ say " Make sure it exists in Apple Developer Portal.", :yellow
2612
+ rescue Mysigner::ClientError => e
2613
+ if e.message.include?("already exists")
2614
+ say "ℹ️ App Group already registered: #{identifier}", :yellow
2615
+ else
2616
+ error "Failed to register App Group: #{e.message}"
2617
+ end
2618
+ exit 1
2619
+ end
2620
+
2621
+ when 'delete'
2622
+ if identifier.nil?
2623
+ error "Usage: mysigner app-group delete IDENTIFIER"
2624
+ exit 1
2625
+ end
2626
+
2627
+ say "📦 Removing App Group...", :cyan
2628
+ say ""
2629
+
2630
+ begin
2631
+ # First find the app group by identifier
2632
+ response = client.get(
2633
+ "/api/v1/organizations/#{config.current_organization_id}/app_groups",
2634
+ params: { q: identifier }
2635
+ )
2636
+ app_groups = response[:data]['app_groups'] || []
2637
+ g = app_groups.find { |x| x['identifier'] == identifier }
2638
+
2639
+ if g.nil?
2640
+ error "App Group not found: #{identifier}"
2641
+ exit 1
2642
+ end
2643
+
2644
+ client.delete(
2645
+ "/api/v1/organizations/#{config.current_organization_id}/app_groups/#{g['id']}"
2646
+ )
2647
+
2648
+ say "✓ App Group removed from My Signer: #{identifier}", :green
2649
+ say ""
2650
+ say " Note: The App Group still exists in Apple Developer Portal.", :yellow
2651
+ say " Delete it manually if needed.", :yellow
2652
+ rescue Mysigner::ClientError => e
2653
+ error "Failed to remove App Group: #{e.message}"
2654
+ exit 1
2655
+ end
2656
+
2657
+ else
2658
+ error "Unknown action: #{action}"
2659
+ say ""
2660
+ say "Available actions:", :yellow
2661
+ say " mysigner app-group register IDENTIFIER [--name NAME]", :cyan
2662
+ say " mysigner app-group delete IDENTIFIER", :cyan
2663
+ exit 1
2664
+ end
2665
+ end
2666
+ end
2667
+ end
2668
+ end
2669
+ end
2670
+ end