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,29 @@
|
|
|
1
|
+
require "ruby_native/cli/command"
|
|
2
|
+
|
|
3
|
+
module RubyNative
|
|
4
|
+
class CLI
|
|
5
|
+
# The app icon: a 1024x1024 PNG with no transparency. It goes in the asset
|
|
6
|
+
# catalog, so a new one ships with the next build.
|
|
7
|
+
class Icon < Command
|
|
8
|
+
def run
|
|
9
|
+
ensure_authenticated!
|
|
10
|
+
app_id = app_id!
|
|
11
|
+
|
|
12
|
+
unless positional.first == "set" && positional[1]
|
|
13
|
+
puts "Usage: ruby_native icon set <path/to/icon.png>"
|
|
14
|
+
exit 1
|
|
15
|
+
end
|
|
16
|
+
finish(self.class.set(app_id, positional[1]))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.set(app_id, path)
|
|
20
|
+
return Outcome.new(ok: false, text: "No file at #{path}.", data: {}) unless File.file?(path)
|
|
21
|
+
|
|
22
|
+
result = Mcp::Platform.put_file("/api/v1/apps/#{app_id}/icon", field: "icon", file: path, content_type: "image/png")
|
|
23
|
+
return failure(result) unless result.ok?
|
|
24
|
+
|
|
25
|
+
success("Icon set from #{path}. #{result.value["note"]}", result.value)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "ruby_native/cli/command"
|
|
2
|
+
require "ruby_native/cli/mcp/platform"
|
|
3
|
+
|
|
4
|
+
module RubyNative
|
|
5
|
+
class CLI
|
|
6
|
+
# Links this project to an app on rubynative.com by writing
|
|
7
|
+
# `ruby_native.app_id` into config/ruby_native.yml. Every command that
|
|
8
|
+
# talks to rubynative.com reads it from there. Deploy used to be the only
|
|
9
|
+
# way to link, which put the first honest status read behind a build.
|
|
10
|
+
class Link < Command
|
|
11
|
+
def run
|
|
12
|
+
ensure_authenticated!
|
|
13
|
+
finish(self.class.link(positional.first || flag("app")))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.link(app_id = nil)
|
|
17
|
+
result = Mcp::Platform.get("/api/v1/apps")
|
|
18
|
+
return failure(result) unless result.ok?
|
|
19
|
+
|
|
20
|
+
apps = Array(result.value)
|
|
21
|
+
return Outcome.new(ok: false, text: "No apps on this account yet. Create one at #{Mcp::Platform::HOST}/apps/new, then run this again.", data: { "apps" => [] }) if apps.empty?
|
|
22
|
+
|
|
23
|
+
app = if app_id.to_s.strip.empty?
|
|
24
|
+
apps.one? ? apps.first : nil
|
|
25
|
+
else
|
|
26
|
+
apps.find { |candidate| candidate["public_id"] == app_id.to_s.strip }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
unless app
|
|
30
|
+
lines = [ app_id ? "No app #{app_id.inspect} on this account. Pick one:" : "Which app? Run `ruby_native link APP_ID` with one of these:" ]
|
|
31
|
+
apps.each { |candidate| lines << " #{candidate["public_id"]} #{candidate["name"]}#{" (#{candidate["url"]})" if candidate["url"]}" }
|
|
32
|
+
return Outcome.new(ok: false, text: lines.join("\n"), data: { "apps" => apps })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Mcp::Platform.write_app_id(app["public_id"])
|
|
36
|
+
success("Linked to #{app["name"]} (#{app["public_id"]}). Wrote ruby_native.app_id to #{Mcp::Platform::CONFIG_PATH}.", { "app_id" => app["public_id"], "name" => app["name"] })
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
require "erb"
|
|
2
|
+
require "yaml"
|
|
3
|
+
require "ruby_native/cli/command"
|
|
4
|
+
|
|
5
|
+
module RubyNative
|
|
6
|
+
class CLI
|
|
7
|
+
# The App Store listing, written in config/ruby_native_listing.yml and
|
|
8
|
+
# staged with rubynative.com. `push` stops there. `publish` is a different
|
|
9
|
+
# verb on purpose: the agent prompt names `push`, so following it can never
|
|
10
|
+
# put words on the store, and someone shipping from a terminal types the
|
|
11
|
+
# same act as pressing the button.
|
|
12
|
+
class Listing < Command
|
|
13
|
+
PATH = "config/ruby_native_listing.yml".freeze
|
|
14
|
+
FIELDS = %w[name subtitle primary_category secondary_category keywords whats_new privacy_policy_url support_url support_email website copyright description].freeze
|
|
15
|
+
CONTACT_FIELDS = %w[first_name last_name email phone].freeze
|
|
16
|
+
LIMITS = { "name" => 30, "subtitle" => 30, "keywords" => 100, "whats_new" => 4000, "description" => 4000 }.freeze
|
|
17
|
+
# An `ios:` or `android:` block replaces these for one store: Apple
|
|
18
|
+
# refuses a listing that mentions Android, and Play's short description
|
|
19
|
+
# (the subtitle) has 80 characters. The rest is Apple's alone.
|
|
20
|
+
STORE_FIELDS = %w[name subtitle description whats_new].freeze
|
|
21
|
+
BLOCK_FIELDS = %w[description whats_new].freeze
|
|
22
|
+
PLAY_LIMITS = { "name" => 30, "subtitle" => 80, "description" => 4000, "whats_new" => 500 }.freeze
|
|
23
|
+
STORES = { "ios" => "the App Store", "android" => "Google Play" }.freeze
|
|
24
|
+
|
|
25
|
+
def run
|
|
26
|
+
ensure_authenticated!
|
|
27
|
+
app_id = app_id!
|
|
28
|
+
|
|
29
|
+
case positional.first
|
|
30
|
+
when "push" then finish(self.class.push(app_id))
|
|
31
|
+
when "pull" then finish(self.class.pull(app_id))
|
|
32
|
+
when "publish" then publish_each(app_id)
|
|
33
|
+
when "revert" then finish(self.class.revert(app_id, positional.drop(1), platform: @argv.include?("--android") ? "android" : "ios"))
|
|
34
|
+
else
|
|
35
|
+
puts "Usage: ruby_native listing <push|pull|publish|revert FIELD...> [--ios|--android]"
|
|
36
|
+
puts " push Save #{PATH} to Ruby Native. Nothing reaches a store."
|
|
37
|
+
puts " pull Write #{PATH} from what Ruby Native holds."
|
|
38
|
+
puts " publish Push, then send the listing to every store the app has started."
|
|
39
|
+
puts " revert Take App Store Connect's words (Google Play Console's with --android) for each field named."
|
|
40
|
+
puts " --ios Publish to App Store Connect only."
|
|
41
|
+
puts " --android Publish the title and descriptions to Google Play only."
|
|
42
|
+
exit 1
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# One push, then each store.
|
|
47
|
+
def publish_each(app_id)
|
|
48
|
+
pushed = self.class.push(app_id)
|
|
49
|
+
return finish(pushed) unless pushed.ok
|
|
50
|
+
|
|
51
|
+
adopted = self.class.store_lines(pushed.data.merge("conflicts" => []))
|
|
52
|
+
puts adopted.join("\n") if adopted.any?
|
|
53
|
+
finish_each(platforms!(app_id)) { |platform| self.class.send_to_store(app_id, platform) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.push(app_id)
|
|
57
|
+
listing = read_file
|
|
58
|
+
return listing if listing.is_a?(Outcome)
|
|
59
|
+
|
|
60
|
+
problems = over_limits(listing)
|
|
61
|
+
return Outcome.new(ok: false, text: ([ "#{PATH} has fields over the stores' limits:" ] + problems).join("\n"), data: { "errors" => problems }) if problems.any?
|
|
62
|
+
|
|
63
|
+
contact = listing.delete("review_contact")
|
|
64
|
+
body = { listing: listing }
|
|
65
|
+
body[:review_contact] = contact if contact&.any?
|
|
66
|
+
result = Mcp::Platform.patch("/api/v1/apps/#{app_id}/listing", body)
|
|
67
|
+
return failure(result) unless result.ok?
|
|
68
|
+
|
|
69
|
+
changed = Array(result.value["changed"])
|
|
70
|
+
lines = [ changed.empty? ? "Nothing changed." : "Saved #{changed.join(", ")}." ]
|
|
71
|
+
missing = Array(result.value["missing"])
|
|
72
|
+
lines << "Still missing before it can be submitted: #{missing.join(", ")}." if missing.any?
|
|
73
|
+
lines.concat(store_lines(result.value))
|
|
74
|
+
lines << "Nothing has reached Apple. `ruby_native listing publish` sends it, or publish from the dashboard."
|
|
75
|
+
success(lines.join("\n"), result.value)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.revert(app_id, fields, platform: "ios")
|
|
79
|
+
return Outcome.new(ok: false, text: "Name the fields to revert, like `ruby_native listing revert name subtitle`.", data: {}) if fields.empty?
|
|
80
|
+
|
|
81
|
+
body = { revert: fields }
|
|
82
|
+
body[:platform] = "android" if platform == "android"
|
|
83
|
+
result = Mcp::Platform.patch("/api/v1/apps/#{app_id}/listing", body)
|
|
84
|
+
return failure(result) unless result.ok?
|
|
85
|
+
|
|
86
|
+
store = platform == "android" ? "Google Play Console" : "App Store Connect"
|
|
87
|
+
success("Took #{store}'s #{fields.map { |field| field.tr("_", " ") }.join(", ")}. Run `ruby_native listing pull` so #{PATH} has it too, or the next push sends yours again.", result.value)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# What reading the stores found: words someone changed there that Ruby Native took, and fields both sides changed.
|
|
91
|
+
# A server too old to read the stores sends neither.
|
|
92
|
+
def self.store_lines(value)
|
|
93
|
+
lines = []
|
|
94
|
+
adopted = Array(value["adopted"])
|
|
95
|
+
if adopted.any?
|
|
96
|
+
lines << "Took #{adopted.map { |row| "#{store_name(row)}'s #{row["field"].to_s.tr("_", " ")}" }.join(", ")}, changed there since the last publish. " \
|
|
97
|
+
"Run `ruby_native listing pull` so #{PATH} has it."
|
|
98
|
+
end
|
|
99
|
+
Array(value["conflicts"]).each do |row|
|
|
100
|
+
flag = row["platform"] == "android" ? " --android" : ""
|
|
101
|
+
lines << "#{store_name(row)} has a different #{row["field"].to_s.tr("_", " ")}: #{row["store"].to_s.inspect}. Publishing sends yours over it; " \
|
|
102
|
+
"`ruby_native listing revert #{row["field"]}#{flag}` takes #{store_name(row)}'s."
|
|
103
|
+
end
|
|
104
|
+
lines
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.store_name(row) = row["platform"] == "android" ? "Google Play Console" : "App Store Connect"
|
|
108
|
+
|
|
109
|
+
def self.pull(app_id)
|
|
110
|
+
result = Mcp::Platform.get("/api/v1/apps/#{app_id}/listing")
|
|
111
|
+
return failure(result) unless result.ok?
|
|
112
|
+
|
|
113
|
+
listing = (result.value["listing"] || {}).merge("review_contact" => result.value["review_contact"] || {})
|
|
114
|
+
File.write(PATH, render(listing))
|
|
115
|
+
success("Wrote #{PATH} from the listing Ruby Native holds.", result.value)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.publish(app_id, platform: "ios")
|
|
119
|
+
pushed = push(app_id)
|
|
120
|
+
return pushed unless pushed.ok
|
|
121
|
+
|
|
122
|
+
send_to_store(app_id, platform)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.send_to_store(app_id, platform)
|
|
126
|
+
result = Mcp::Platform.post("/api/v1/apps/#{app_id}/listing/publish", platform == "android" ? { platform: "android" } : nil)
|
|
127
|
+
return failure(result) unless result.ok?
|
|
128
|
+
|
|
129
|
+
success(result.value["note"].to_s, result.value)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ERB first so a description can interpolate, then YAML. Only known
|
|
133
|
+
# keys go up; anything else in the file is left alone and reported.
|
|
134
|
+
def self.read_file
|
|
135
|
+
return Outcome.new(ok: false, text: "No #{PATH}. Run `ruby_native listing pull` to start one from the dashboard's draft, or write it by hand.", data: {}) unless File.exist?(PATH)
|
|
136
|
+
|
|
137
|
+
data = YAML.safe_load(ERB.new(File.read(PATH, encoding: "UTF-8")).result, aliases: true)
|
|
138
|
+
return Outcome.new(ok: false, text: "#{PATH} has to be a YAML mapping of listing fields.", data: {}) unless data.is_a?(Hash)
|
|
139
|
+
|
|
140
|
+
# An empty line from the template is a field not written yet, not a
|
|
141
|
+
# request to blank it.
|
|
142
|
+
listing = data.slice(*FIELDS).compact.transform_values { |value| value.to_s.strip }
|
|
143
|
+
# Every push sends both blocks, so one deleted from the file stops overriding.
|
|
144
|
+
STORES.each_key do |store|
|
|
145
|
+
block = data[store] || {}
|
|
146
|
+
return Outcome.new(ok: false, text: "#{store}: in #{PATH} has to be a mapping of #{STORE_FIELDS.join(", ")}.", data: {}) unless block.is_a?(Hash)
|
|
147
|
+
|
|
148
|
+
shared = block.keys.map(&:to_s) - STORE_FIELDS
|
|
149
|
+
return Outcome.new(ok: false, text: "#{store}: in #{PATH} can only set #{STORE_FIELDS.join(", ")}; #{shared.join(", ")} #{shared.one? ? "is" : "are"} shared by every store.", data: {}) if shared.any?
|
|
150
|
+
|
|
151
|
+
listing[store] = block.transform_keys(&:to_s).compact.transform_values { |value| value.to_s.strip }.reject { |_, value| value.empty? }
|
|
152
|
+
end
|
|
153
|
+
contact = data["review_contact"]
|
|
154
|
+
listing["review_contact"] = contact.slice(*CONTACT_FIELDS).compact.transform_values { |value| value.to_s.strip } if contact.is_a?(Hash)
|
|
155
|
+
listing
|
|
156
|
+
rescue Psych::SyntaxError => error
|
|
157
|
+
Outcome.new(ok: false, text: "#{PATH} is not valid YAML: #{error.message}", data: {})
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Checked on what each store will get: a long shared subtitle is fine
|
|
161
|
+
# when `ios:` has its own.
|
|
162
|
+
def self.over_limits(listing)
|
|
163
|
+
{ "ios" => LIMITS, "android" => PLAY_LIMITS }.flat_map do |store, limits|
|
|
164
|
+
own = listing[store] || {}
|
|
165
|
+
limits.filter_map do |field, limit|
|
|
166
|
+
# Shared release notes are cut to Play's 500 on the way out; only Play's own must fit.
|
|
167
|
+
next if store == "android" && field == "whats_new" && !own[field]
|
|
168
|
+
|
|
169
|
+
value = own[field] || listing[field]
|
|
170
|
+
length = value.to_s.length
|
|
171
|
+
next unless length > limit
|
|
172
|
+
|
|
173
|
+
" #{"#{store}." if own[field]}#{field} is #{length} characters, the limit on #{STORES[store]} is #{limit}"
|
|
174
|
+
end
|
|
175
|
+
end.uniq
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Every field is written, empty ones with their limit, so a pull on an
|
|
179
|
+
# app with no listing yet is a template rather than a file of comments
|
|
180
|
+
# that push then refuses.
|
|
181
|
+
def self.render(listing)
|
|
182
|
+
lines = [ "# App Store and Google Play listing. `ruby_native listing push` saves it to Ruby Native;", "# publishing to a store is a separate step.", "" ]
|
|
183
|
+
FIELDS.each do |field|
|
|
184
|
+
lines.concat(render_contact(listing["review_contact"] || {})) if field == "description"
|
|
185
|
+
value = listing[field]
|
|
186
|
+
if value.nil? || value.to_s.empty?
|
|
187
|
+
lines << "#{field}:#{" # up to #{LIMITS[field]} characters" if LIMITS[field]}"
|
|
188
|
+
next
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
if BLOCK_FIELDS.include?(field)
|
|
192
|
+
lines << "#{field}: |-"
|
|
193
|
+
value.to_s.each_line { |line| lines << " #{line.rstrip}" }
|
|
194
|
+
else
|
|
195
|
+
lines << "#{field}: #{scalar(value)}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
lines << ""
|
|
199
|
+
lines << "# ios: and android: replace name, subtitle, description, or whats_new for one store."
|
|
200
|
+
STORES.each_key do |store|
|
|
201
|
+
block = (listing[store] || {}).slice(*STORE_FIELDS).reject { |_, value| value.to_s.empty? }
|
|
202
|
+
next if block.empty?
|
|
203
|
+
|
|
204
|
+
lines << "#{store}:"
|
|
205
|
+
block.each do |field, value|
|
|
206
|
+
if BLOCK_FIELDS.include?(field)
|
|
207
|
+
lines << " #{field}: |-"
|
|
208
|
+
value.to_s.each_line { |line| lines << " #{line.rstrip}" }
|
|
209
|
+
else
|
|
210
|
+
lines << " #{field}: #{scalar(value)}"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
lines.join("\n") + "\n"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def self.render_contact(contact)
|
|
218
|
+
lines = [ "review_contact: # who App Review contacts; never shown on the store" ]
|
|
219
|
+
CONTACT_FIELDS.each do |field|
|
|
220
|
+
value = contact[field]
|
|
221
|
+
lines << if value.nil? || value.to_s.empty?
|
|
222
|
+
" #{field}:#{" # \"+1 503 555 0100\", quoted" if field == "phone"}"
|
|
223
|
+
else
|
|
224
|
+
" #{field}: #{scalar(value)}"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
lines
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# YAML reads an unquoted +15035550100 as a number and drops the +.
|
|
231
|
+
def self.scalar(value)
|
|
232
|
+
value.to_s.include?(":") || value.to_s.start_with?("#", "+") ? value.to_s.inspect : value
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|