ruby_native 0.17.3 → 0.18.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.
@@ -0,0 +1,80 @@
1
+ require "ruby_native/cli/command"
2
+
3
+ module RubyNative
4
+ class CLI
5
+ # The strings iOS shows when the app first asks for the camera, the
6
+ # microphone, or the photo library. They land in Info.plist, so a change
7
+ # ships with the next build.
8
+ class Permissions < Command
9
+ NAMES = %w[camera microphone photos].freeze
10
+
11
+ def run
12
+ ensure_authenticated!
13
+ app_id = app_id!
14
+
15
+ case positional.first
16
+ when "set"
17
+ name, text = positional[1], positional[2]
18
+ unless NAMES.include?(name) && text
19
+ puts "Usage: ruby_native permissions set <#{NAMES.join("|")}> \"<why the app asks>\""
20
+ exit 1
21
+ end
22
+ finish(self.class.set(app_id, name => text))
23
+ when "list", nil
24
+ finish(self.class.list(app_id))
25
+ else
26
+ puts "Unknown permissions command #{positional.first.inspect}. Use `set` or `list`."
27
+ exit 1
28
+ end
29
+ end
30
+
31
+ def self.list(app_id)
32
+ result = Mcp::Platform.get("/api/v1/apps/#{app_id}/permissions")
33
+ return failure(result) unless result.ok?
34
+
35
+ success(render(result.value), result.value)
36
+ end
37
+
38
+ def self.set(app_id, permissions)
39
+ unknown = permissions.keys.map(&:to_s) - NAMES
40
+ return Outcome.new(ok: false, text: "Unknown permission #{unknown.first.inspect}. Use #{NAMES.join(", ")}.", data: {}) if unknown.any?
41
+
42
+ result = Mcp::Platform.patch("/api/v1/apps/#{app_id}/permissions", { permissions: permissions })
43
+ return failure(result) unless result.ok?
44
+
45
+ changed = Array(result.value["changed"])
46
+ lines = [ render(result.value) ]
47
+ lines << ""
48
+ lines << (changed.empty? ? "Nothing changed." : "Changed: #{changed.join(", ")}. #{result.value["note"]}")
49
+ success(lines.join("\n"), result.value)
50
+ end
51
+
52
+ # Without a camera sentence the build has no NSCameraUsageDescription, so the
53
+ # native scanner refuses and a scan button does nothing. Returns where the
54
+ # views scan when the app has none; nil when it's fine or can't be told.
55
+ def self.scan_without_camera(app_id, paths: [ "app/views" ])
56
+ use = paths.flat_map { |path| Dir.glob(File.join(path, "**", "*.erb")) }.sort.lazy.filter_map do |file|
57
+ line = File.foreach(file).with_index(1).find { |text, _| text.include?("native_scan_button_tag") }&.last
58
+ [ file, line ] if line
59
+ end.first
60
+ return nil unless use
61
+
62
+ result = Mcp::Platform.get("/api/v1/apps/#{app_id}/permissions")
63
+ return nil unless result.ok?
64
+
65
+ camera = result.value.is_a?(Hash) ? result.value.dig("permissions", "camera") : nil
66
+ camera.to_s.strip.empty? ? use : nil
67
+ end
68
+
69
+ def self.scan_without_camera_message(file, line)
70
+ "#{file}:#{line} uses native_scan_button_tag, but the app has no camera sentence, so Scan won't open. " \
71
+ "Add one with `bundle exec ruby_native permissions set camera \"Why the app uses the camera\"`; it ships with the next deploy."
72
+ end
73
+
74
+ def self.render(value)
75
+ permissions = value.is_a?(Hash) ? value["permissions"] || {} : {}
76
+ NAMES.map { |name| "#{name.ljust(11)} #{permissions[name].to_s.empty? ? "(not set, the app cannot ask)" : permissions[name]}" }.join("\n")
77
+ end
78
+ end
79
+ end
80
+ end
@@ -335,7 +335,7 @@ module RubyNative
335
335
  puts ""
336
336
  puts "Like what you see? `ruby_native deploy` puts a real app on your phone for good."
337
337
  puts ""
338
- puts "Free until you ship: #{DEPLOY_URL}"
338
+ puts "How it works: #{DEPLOY_URL}"
339
339
  end
340
340
 
341
341
  def trap_interrupt
@@ -0,0 +1,111 @@
1
+ require "ruby_native/cli/command"
2
+
3
+ module RubyNative
4
+ class CLI
5
+ # Store screenshots, captured by rubynative.com against the deployed site.
6
+ # Each store has its own pages; the key, one per app, is only for pages
7
+ # behind a sign-in. A capture is a job to follow. They reach the store
8
+ # with the listing: `listing publish`, or the dashboard.
9
+ class Screenshots < Command
10
+ def run
11
+ ensure_authenticated!
12
+ app_id = app_id!
13
+
14
+ case positional.first
15
+ when "setup"
16
+ paths = flag("paths").to_s.split(",").map(&:strip).reject(&:empty?)
17
+ finish(self.class.setup(app_id, paths: paths, platforms: (platforms!(app_id) if paths.any?),
18
+ sign_in: @argv.include?("--sign-in"), rotate_key: @argv.include?("--rotate-key")))
19
+ when "capture"
20
+ finish_each(platforms!(app_id)) { |platform| self.class.capture(app_id, platform: platform) }
21
+ when "list", nil
22
+ finish(self.class.list(app_id))
23
+ else
24
+ puts "Unknown screenshots command #{positional.first.inspect}. Use `setup`, `capture`, or `list`."
25
+ exit 1
26
+ end
27
+ end
28
+
29
+ # Sets each store's pages when given (no platforms sets both). The key is
30
+ # only for pages behind a sign-in: generated with sign_in when there is
31
+ # none, or replaced with rotate_key. It comes back once, with where to put it.
32
+ def self.setup(app_id, paths: [], platforms: nil, sign_in: false, rotate_key: false)
33
+ current = Mcp::Platform.get("/api/v1/apps/#{app_id}/screenshots")
34
+ return failure(current) unless current.ok?
35
+
36
+ lines = []
37
+ data = current.value.dup
38
+ if rotate_key || (sign_in && !current.value["key_configured"])
39
+ key = Mcp::Platform.post("/api/v1/apps/#{app_id}/screenshots/key")
40
+ return failure(key) unless key.ok?
41
+
42
+ data["screenshot_key"] = key.value["screenshot_key"]
43
+ lines << (key.value["rotated"] ? "Rotated the screenshot key. The old one stopped working." : "Generated the screenshot key.")
44
+ lines << ""
45
+ lines << " #{key.value["screenshot_key"]}"
46
+ lines << ""
47
+ lines << "Add it to the app's Rails credentials and read it in an initializer, then deploy:"
48
+ lines << ""
49
+ lines << " # bin/rails credentials:edit"
50
+ lines << " ruby_native:"
51
+ lines << " screenshot_key: <the key above>"
52
+ lines << ""
53
+ lines << " # config/initializers/ruby_native.rb"
54
+ lines << " RubyNative.configure { |c| c.screenshot_key = Rails.application.credentials.ruby_native.screenshot_key }"
55
+ lines << ""
56
+ elsif sign_in
57
+ lines << "The screenshot key is already set. Pass --rotate-key to replace it."
58
+ end
59
+
60
+ if paths.any?
61
+ (platforms || [ nil ]).each do |platform|
62
+ updated = Mcp::Platform.patch("/api/v1/apps/#{app_id}/screenshots", platform ? { paths: paths, platform: platform } : { paths: paths })
63
+ return failure(updated) unless updated.ok?
64
+
65
+ data = updated.value.merge(data.slice("screenshot_key"))
66
+ end
67
+ stores = (platforms || %w[ios android]).map { |platform| PLATFORM_LABELS[platform] }.join(" and ")
68
+ lines << "Pages to capture for #{stores}: #{paths.join(", ")}"
69
+ elsif Array(current.value["paths"]).empty?
70
+ lines << "No pages to capture yet. Run this again with --paths /,/inbox,/settings (3 to 10 pages)."
71
+ end
72
+
73
+ lines << "Then: `ruby_native screenshots capture`."
74
+ success(lines.join("\n"), data)
75
+ end
76
+
77
+ def self.capture(app_id, platform: "ios")
78
+ result = Mcp::Platform.post("/api/v1/apps/#{app_id}/screenshots/capture", { platform: platform })
79
+ return failure(result) unless result.ok?
80
+
81
+ capture = result.value["capture"] || {}
82
+ success("Capturing #{platform} screenshots (capture #{capture["id"]}). #{result.value["note"]}", result.value)
83
+ end
84
+
85
+ def self.list(app_id)
86
+ result = Mcp::Platform.get("/api/v1/apps/#{app_id}/screenshots")
87
+ return failure(result) unless result.ok?
88
+
89
+ value = result.value
90
+ lines = []
91
+ lines << "Sign-in key: #{value["key_configured"] ? "set" : "not set (only pages behind a sign-in need one: `ruby_native screenshots setup --sign-in`)"}"
92
+ pages = value["pages"] || { "ios" => value["paths"] }
93
+ pages.each do |platform, paths|
94
+ paths = Array(paths)
95
+ lines << "#{PLATFORM_LABELS.fetch(platform, platform)} pages: #{paths.empty? ? "none yet" : paths.join(", ")}"
96
+ end
97
+ (value["captures"] || {}).each do |platform, capture|
98
+ label = platform == "ios" ? "iOS" : "Android"
99
+ if capture.nil?
100
+ lines << "#{label}: no capture yet"
101
+ else
102
+ state = capture["in_progress"] ? "in progress" : capture["status"]
103
+ lines << "#{label}: capture #{capture["id"]} #{state}, #{capture["screenshots"]} screenshots#{capture["upload_status"] ? ", #{capture["upload_status"].tr("_", " ")}" : ""}"
104
+ end
105
+ end
106
+ lines << "Publish them to the stores from #{value["dashboard_url"]}." if value["dashboard_url"]
107
+ success(lines.join("\n"), value)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,16 @@
1
+ require "ruby_native/cli/mcp/prompts"
2
+
3
+ module RubyNative
4
+ class CLI
5
+ # Prints the `ship` prompt, for an agent whose client cannot show an MCP
6
+ # prompt as a command. Same text, pasted instead of picked.
7
+ class Ship
8
+ def initialize(_argv = [])
9
+ end
10
+
11
+ def run
12
+ puts Mcp::Prompts::SHIP
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,196 @@
1
+ require "ruby_native/cli/credentials"
2
+ require "ruby_native/cli/mcp/platform"
3
+
4
+ module RubyNative
5
+ class CLI
6
+ # Where the app is and what is in the way, straight from the same answer
7
+ # the dashboard reads. The MCP `status` tool renders exactly this, so a
8
+ # person at a terminal and an agent see one report.
9
+ #
10
+ # A blocker names who clears it. "human" is a hand-off: the CLI prints the
11
+ # steps and stops, because the step is one only a person can take (pay,
12
+ # create the app record, answer a questionnaire Apple gives us no API for).
13
+ class Status
14
+ def initialize(argv = [])
15
+ @app_id = argv.find { |arg| arg.start_with?("--app=") }&.split("=", 2)&.last || argv.find { |arg| !arg.start_with?("--") }
16
+ end
17
+
18
+ # What the layout has to carry before the app looks like one. Read as
19
+ # substrings across every layout, so a helper split over lines or moved
20
+ # into a partial the layout renders is missed: the report says "missing"
21
+ # and the person can see it is not. Wrong the safe way round.
22
+ LAYOUT_GLOB = "app/views/layouts/**/*.html.erb".freeze
23
+ MARKERS = {
24
+ "stylesheet_link_tag :ruby_native" => "the Ruby Native stylesheet",
25
+ "viewport-fit=cover" => "viewport-fit=cover",
26
+ "native_tabs_tag" => "native_tabs_tag",
27
+ "native_identity_tag" => "native_identity_tag"
28
+ }.freeze
29
+
30
+ def run
31
+ app_id = @app_id || Mcp::Platform.app_id
32
+ if app_id.to_s.strip.empty? || Credentials.token.nil?
33
+ puts self.class.local_report
34
+ return
35
+ end
36
+
37
+ result = self.class.fetch(app_id)
38
+ unless result.ok?
39
+ puts self.class.failure_message(result, app_id)
40
+ exit 1
41
+ end
42
+
43
+ puts self.class.report(result.value)
44
+ end
45
+
46
+ def self.fetch(app_id)
47
+ Mcp::Platform.get("/api/v1/apps/#{app_id}")
48
+ end
49
+
50
+ def self.failure_message(result, app_id)
51
+ return result.error unless result.not_found?
52
+
53
+ "No app #{app_id.inspect} on this account. Check `ruby_native.app_id` in #{Mcp::Platform::CONFIG_PATH}, " \
54
+ "or pass an app id."
55
+ end
56
+
57
+ # --- Before an app is linked ---
58
+
59
+ # Everything `status` can know without the API: the file the generator
60
+ # writes, the four things the first native pass puts in the layout, and
61
+ # whether login and link have happened. The next step follows from which
62
+ # of those is missing first.
63
+ def self.local
64
+ layouts = Dir.glob(LAYOUT_GLOB).sort.map { |path| File.read(path) }.join("\n")
65
+
66
+ {
67
+ "local" => true,
68
+ "gem_version" => RubyNative::VERSION,
69
+ "config" => File.exist?(Mcp::Platform::CONFIG_PATH),
70
+ "layout" => MARKERS.keys.to_h { |marker| [ marker, layouts.include?(marker) ] },
71
+ "app_id" => Mcp::Platform.app_id,
72
+ "logged_in" => !Credentials.token.nil?
73
+ }
74
+ end
75
+
76
+ def self.local_report(local = self.local)
77
+ missing = local["layout"].reject { |_, present| present }.keys.map { |marker| MARKERS[marker] }
78
+
79
+ lines = [ "Ruby Native #{local["gem_version"]}, before an app is linked.", "" ]
80
+ lines << " #{Mcp::Platform::CONFIG_PATH}: #{local["config"] ? "present" : "missing"}"
81
+ lines << " Layout: #{missing.empty? ? "has the first native pass" : "missing #{missing.join(", ")}"}"
82
+ lines << " Login: #{local["logged_in"] ? "stored" : "none"}"
83
+ lines << " Linked app: #{local["app_id"] || "none"}"
84
+ lines << ""
85
+ lines << "Next: #{local_next(local, missing)}"
86
+ lines.join("\n")
87
+ end
88
+
89
+ def self.local_next(local, missing)
90
+ if !local["config"]
91
+ "run `bin/rails generate ruby_native:install`."
92
+ elsif missing.any?
93
+ "the first native pass, the seven changes in the `ship` prompt (`ruby_native ship` prints it). " \
94
+ "Then `bundle exec ruby_native preview` to see it on a phone."
95
+ elsif !local["logged_in"]
96
+ "create the app at #{Mcp::Platform::HOST}/apps/new (a name, the site's URL, and an account if you have none), " \
97
+ "then `ruby_native login` and `ruby_native link`."
98
+ else
99
+ "run `ruby_native link` to pick the app on your account."
100
+ end
101
+ end
102
+
103
+ # --- Rendering ---
104
+
105
+ def self.report(status)
106
+ status = status.is_a?(Hash) ? status : {}
107
+ ios = status["ios"] || {}
108
+ android = status["android"] || {}
109
+ subscription = status["subscription"] || {}
110
+
111
+ lines = []
112
+ lines << "#{status["name"]} (#{status["public_id"]}) · #{status["url"]}"
113
+ lines << "Dashboard: #{status["dashboard_url"]}" if status["dashboard_url"]
114
+ lines << ""
115
+ lines << "iOS: #{track_summary(ios, store: "the App Store")}"
116
+ lines << "Android: #{track_summary(android, store: "Google Play")}"
117
+ lines << "Subscription: #{subscription_summary(subscription)}"
118
+ lines << ""
119
+ lines.concat(next_lines(ios, "iOS"))
120
+ lines << ""
121
+ lines.concat(next_lines(android, "Android"))
122
+
123
+ lines.join("\n")
124
+ end
125
+
126
+ def self.track_summary(track, store:)
127
+ parts = [ track["setup"] ]
128
+ build = track["latest_build"]
129
+ parts << (build ? "build #{build["number"]} (#{build["version"]}) #{build["status"]}" : "no build yet")
130
+ parts << (track["live"] ? "live on #{store}" : "not on #{store}")
131
+ parts.compact.join(" · ")
132
+ end
133
+
134
+ def self.subscription_summary(subscription)
135
+ return "past due, builds paused" if subscription["past_due"]
136
+ return "none" unless subscription["active"]
137
+
138
+ subscription["plan"] ? "#{subscription["plan"]}, active" : "active"
139
+ end
140
+
141
+ # One track at a time: the stores gate differently, and an app can be
142
+ # live on one and unbuilt on the other.
143
+ def self.next_lines(track, label)
144
+ blockers = Array(track["blocked_by"])
145
+ act = track["next"].to_s
146
+ android = label == "Android"
147
+
148
+ if blockers.empty?
149
+ update = track["update"] || {}
150
+ return [ update_line(update, label, android) ] if act == "build" && update["due"]
151
+
152
+ return [ case act
153
+ when "submit" then "#{label}: nothing is in the way of submitting for review. Run `bundle exec ruby_native submit`."
154
+ when "wait" then "#{label}: in App Store review. Nothing to do but wait; `ruby_native withdraw` takes it back out."
155
+ when "release" then "#{label}: approved and waiting for you. Press Release on the Submit page at rubynative.com."
156
+ when "go_live" then "#{label}: everything Google Play needs is in place."
157
+ else "#{label}: nothing is in the way of a build. Run `bundle exec ruby_native deploy#{" --android" if android}`."
158
+ end ]
159
+ end
160
+
161
+ verb = case act
162
+ when "submit" then "submitting"
163
+ when "go_live" then "going live on Google Play"
164
+ else "building"
165
+ end
166
+ [ "#{label}: before #{verb}, #{blockers.size} #{blockers.size == 1 ? "thing needs" : "things need"} doing:", "" ] +
167
+ blocker_lines(blockers)
168
+ end
169
+
170
+ # A live store due a build: why, the version, and the site's gem first when the release needs it.
171
+ def self.update_line(update, label, android)
172
+ command = "`bundle exec ruby_native deploy#{" --android" if android} --version=#{update["version"]}`"
173
+ gem_step = update["gem_step"].to_s.sub(/\.\z/, "")
174
+ how = gem_step.empty? ? command : "#{gem_step[0].downcase}#{gem_step[1..]}, then #{command}"
175
+ "#{label}: #{update["reason"]} Release version #{update["version"]}: #{how}."
176
+ end
177
+
178
+ # The steps are the same copy the dashboard shows, so a person reading
179
+ # them off a terminal and one reading them off the web are told the
180
+ # same thing.
181
+ def self.blocker_lines(blockers)
182
+ blockers.each_with_index.flat_map do |blocker, index|
183
+ who = blocker["who"] == "agent" ? "an agent can do this" : "a person does this"
184
+ seen = blocker["verifiable"] ? "Ruby Native sees it clear" : "Ruby Native cannot see it, so it is marked done on the dashboard"
185
+
186
+ lines = [ "#{index + 1}. #{blocker["id"]} (#{who}; #{seen})" ]
187
+ lines << " #{blocker["why"]}" if blocker["why"]
188
+ lines << " #{blocker["url"]}" if blocker["url"]
189
+ Array(blocker["steps"]).each_with_index { |step, i| lines << " #{i + 1}. #{step}" }
190
+ lines << ""
191
+ lines
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,91 @@
1
+ require "ruby_native/cli/command"
2
+
3
+ module RubyNative
4
+ class CLI
5
+ # Submitting for App Store review is two calls. The first returns exactly
6
+ # what would go to Apple and a token that expires in minutes; the second
7
+ # needs the token. Here the person reads the summary and answers; the MCP
8
+ # tool shows the model the same summary and tells it to ask.
9
+ class Submit < Command
10
+ def run
11
+ ensure_authenticated!
12
+ app_id = app_id!
13
+
14
+ # A questionnaire Apple or Google gives us no API for closes on the
15
+ # person's word, and the word can ride along: --confirm=app_privacy.
16
+ confirmations = @argv.select { |arg| arg.start_with?("--confirm=") }.map { |arg| arg.split("=", 2).last.to_s.strip }.reject(&:empty?)
17
+ platforms = platforms!(app_id)
18
+
19
+ # Play has no submit of ours: the answer is the go-live list with the
20
+ # promotion steps, and nothing to confirm. It prints first so the
21
+ # App Store question is the last thing on screen.
22
+ android = (self.class.preview(app_id, platform: "android", confirmations: confirmations) if platforms.include?("android"))
23
+ return finish(android) unless platforms.include?("ios")
24
+
25
+ if android
26
+ puts "Android:\n#{android.text}\n\niOS:"
27
+ end
28
+ ios = submit_ios(app_id, confirmations)
29
+ puts ios.text
30
+ exit 1 unless ios.ok && (android.nil? || android.ok)
31
+ end
32
+
33
+ def submit_ios(app_id, confirmations)
34
+ preview = self.class.preview(app_id, confirmations: confirmations)
35
+ return preview unless preview.ok
36
+
37
+ puts preview.text
38
+ puts ""
39
+
40
+ unless @argv.include?("--yes")
41
+ print "Submit for review? Type yes to continue: "
42
+ answer = $stdin.gets.to_s.strip.downcase
43
+ return Outcome.new(ok: false, text: "Not submitted.", data: {}) unless answer == "yes"
44
+ end
45
+
46
+ self.class.submit(app_id, preview.data["confirmation_token"])
47
+ end
48
+
49
+ def self.preview(app_id, platform: "ios", confirmations: [])
50
+ body = {}
51
+ body[:platform] = "android" if platform == "android"
52
+ body[:confirm] = confirmations if confirmations.any?
53
+ result = Mcp::Platform.post("/api/v1/apps/#{app_id}/submission", body.empty? ? nil : body)
54
+ return failure(result) unless result.ok?
55
+
56
+ text = result.value["summary"].to_s
57
+ blockers = result.value["blocked_by"]
58
+ if platform == "android" && blockers.is_a?(Array) && blockers.any?
59
+ text += "\n\n" + Status.blocker_lines(blockers).join("\n")
60
+ end
61
+ success(text, result.value)
62
+ end
63
+
64
+ def self.submit(app_id, confirmation_token)
65
+ return Outcome.new(ok: false, text: "No confirmation token. Preview the submission first.", data: {}) if confirmation_token.to_s.empty?
66
+
67
+ result = Mcp::Platform.post("/api/v1/apps/#{app_id}/submission", { confirmation_token: confirmation_token })
68
+ return failure(result) unless result.ok?
69
+
70
+ success(result.value["note"].to_s, result.value)
71
+ end
72
+
73
+ def self.withdraw(app_id, platform: "ios")
74
+ result = Mcp::Platform.request(Net::HTTP::Delete, "/api/v1/apps/#{app_id}/submission#{"?platform=android" if platform == "android"}")
75
+ return failure(result) unless result.ok?
76
+
77
+ success(result.value["note"].to_s, result.value)
78
+ end
79
+ end
80
+
81
+ class Withdraw < Command
82
+ def run
83
+ ensure_authenticated!
84
+ app_id = app_id!
85
+ # Play has no review queue to leave, so only --android asks it.
86
+ platforms = @argv.include?("--android") ? [ "android" ] : [ "ios" ]
87
+ finish_each(platforms) { |platform| Submit.withdraw(app_id, platform: platform) }
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,54 @@
1
+ require "ruby_native/cli/command"
2
+
3
+ module RubyNative
4
+ class CLI
5
+ # Internal TestFlight testers. Adding one reaches App Store Connect, through
6
+ # rubynative.com, which holds the key.
7
+ class Testers < Command
8
+ def run
9
+ ensure_authenticated!
10
+ app_id = app_id!
11
+
12
+ case positional.first
13
+ when "add"
14
+ email = positional[1]
15
+ unless email
16
+ puts "Usage: ruby_native testers add <email> [--ios|--android]"
17
+ exit 1
18
+ end
19
+ finish_each(platforms!(app_id)) { |platform| self.class.add(app_id, email, platform: platform) }
20
+ when "list", nil
21
+ finish_each(platforms!(app_id)) { |platform| self.class.list(app_id, platform: platform) }
22
+ else
23
+ puts "Unknown testers command #{positional.first.inspect}. Use `add` or `list`."
24
+ exit 1
25
+ end
26
+ end
27
+
28
+ # Play keeps its tester list in the Console, so `--android` comes back
29
+ # as the hand-off in the blockers shape rather than a list.
30
+ def self.list(app_id, platform: "ios")
31
+ result = Mcp::Platform.get("/api/v1/apps/#{app_id}/testers#{"?platform=android" if platform == "android"}")
32
+ return failure(result) unless result.ok?
33
+
34
+ testers = Array(result.value["testers"])
35
+ return success("No testers yet. Add one with `ruby_native testers add <email>`.", result.value) if testers.empty?
36
+
37
+ width = testers.map { |tester| tester["email"].to_s.length }.max
38
+ lines = testers.map do |tester|
39
+ "#{tester["email"].to_s.ljust(width)} #{tester["accepted"] ? "accepted" : "invited, not accepted"}"
40
+ end
41
+ success(lines.join("\n"), result.value)
42
+ end
43
+
44
+ def self.add(app_id, email, platform: "ios")
45
+ body = { email: email }
46
+ body[:platform] = "android" if platform == "android"
47
+ result = Mcp::Platform.post("/api/v1/apps/#{app_id}/testers", body)
48
+ return failure(result) unless result.ok?
49
+
50
+ success(result.value["message"].to_s, result.value)
51
+ end
52
+ end
53
+ end
54
+ end