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.
- checksums.yaml +4 -4
- data/README.md +53 -1
- data/lib/generators/ruby_native/install_generator.rb +106 -13
- data/lib/generators/ruby_native/templates/ruby_native.yml +6 -7
- data/lib/ruby_native/cli/apple.rb +76 -0
- data/lib/ruby_native/cli/check.rb +44 -22
- data/lib/ruby_native/cli/command.rb +94 -0
- data/lib/ruby_native/cli/demo_account.rb +36 -0
- data/lib/ruby_native/cli/deploy.rb +33 -12
- data/lib/ruby_native/cli/icon.rb +29 -0
- data/lib/ruby_native/cli/link.rb +40 -0
- data/lib/ruby_native/cli/listing.rb +236 -0
- data/lib/ruby_native/cli/mcp/config_validator.rb +546 -0
- data/lib/ruby_native/cli/mcp/install.rb +54 -0
- data/lib/ruby_native/cli/mcp/platform.rb +172 -0
- data/lib/ruby_native/cli/mcp/prompts.rb +161 -0
- data/lib/ruby_native/cli/mcp.rb +1026 -0
- data/lib/ruby_native/cli/permissions.rb +80 -0
- data/lib/ruby_native/cli/preview.rb +1 -1
- data/lib/ruby_native/cli/screenshots.rb +111 -0
- data/lib/ruby_native/cli/ship.rb +16 -0
- data/lib/ruby_native/cli/status.rb +196 -0
- data/lib/ruby_native/cli/submit.rb +91 -0
- data/lib/ruby_native/cli/testers.rb +54 -0
- data/lib/ruby_native/cli.rb +70 -4
- data/lib/ruby_native/icons/material_symbols.txt +4229 -0
- data/lib/ruby_native/icons/sf_symbols.tsv +9237 -0
- data/lib/ruby_native/version.rb +1 -1
- data/lib/ruby_native.rb +9 -0
- metadata +20 -1
|
@@ -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 "
|
|
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
|