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.
- checksums.yaml +7 -0
- data/.DS_Store +0 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/.travis.yml +7 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +137 -0
- data/LICENSE +201 -0
- data/MANUAL_TEST.md +341 -0
- data/README.md +493 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/exe/mysigner +5 -0
- data/lib/mysigner/build/android_executor.rb +367 -0
- data/lib/mysigner/build/android_parser.rb +293 -0
- data/lib/mysigner/build/configurator.rb +126 -0
- data/lib/mysigner/build/detector.rb +388 -0
- data/lib/mysigner/build/error_analyzer.rb +193 -0
- data/lib/mysigner/build/executor.rb +176 -0
- data/lib/mysigner/build/parser.rb +206 -0
- data/lib/mysigner/cli/auth_commands.rb +1381 -0
- data/lib/mysigner/cli/build_commands.rb +2095 -0
- data/lib/mysigner/cli/concerns/actionable_suggestions.rb +500 -0
- data/lib/mysigner/cli/concerns/api_helpers.rb +131 -0
- data/lib/mysigner/cli/concerns/error_handlers.rb +446 -0
- data/lib/mysigner/cli/concerns/helpers.rb +63 -0
- data/lib/mysigner/cli/diagnostic_commands.rb +1034 -0
- data/lib/mysigner/cli/resource_commands.rb +2670 -0
- data/lib/mysigner/cli.rb +43 -0
- data/lib/mysigner/client.rb +189 -0
- data/lib/mysigner/config.rb +311 -0
- data/lib/mysigner/export/exporter.rb +150 -0
- data/lib/mysigner/signing/certificate_checker.rb +148 -0
- data/lib/mysigner/signing/keystore_manager.rb +239 -0
- data/lib/mysigner/signing/validator.rb +150 -0
- data/lib/mysigner/signing/wizard.rb +784 -0
- data/lib/mysigner/upload/app_store_automation.rb +402 -0
- data/lib/mysigner/upload/app_store_submission.rb +312 -0
- data/lib/mysigner/upload/play_store_uploader.rb +378 -0
- data/lib/mysigner/upload/uploader.rb +373 -0
- data/lib/mysigner/version.rb +3 -0
- data/lib/mysigner.rb +15 -0
- data/mysigner.gemspec +78 -0
- data/test_manual.rb +102 -0
- 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
|