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,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