ruby_native 0.17.2 → 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/config/signals.yml +28 -0
- 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/signal_collector.rb +109 -7
- 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/signals.rb +39 -0
- data/lib/ruby_native/version.rb +1 -1
- data/lib/ruby_native.rb +9 -0
- metadata +20 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
require "ruby_native/cli/credentials"
|
|
2
|
+
require "ruby_native/cli/mcp/platform"
|
|
3
|
+
require "ruby_native/cli/status"
|
|
4
|
+
|
|
5
|
+
module RubyNative
|
|
6
|
+
class CLI
|
|
7
|
+
# What every command that talks to rubynative.com about one app shares:
|
|
8
|
+
# finding the app, being logged in, and turning a refusal into something a
|
|
9
|
+
# person can act on. The MCP server calls the same `perform` methods, so a
|
|
10
|
+
# verb is a CLI command first and a tool second, and both say one thing.
|
|
11
|
+
class Command
|
|
12
|
+
Outcome = Struct.new(:ok, :text, :data, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
def initialize(argv = [])
|
|
15
|
+
@argv = argv
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def app_id!
|
|
21
|
+
flag = @argv.find { |arg| arg.start_with?("--app=") }
|
|
22
|
+
app_id = flag ? flag.split("=", 2).last : Mcp::Platform.app_id
|
|
23
|
+
return app_id unless app_id.to_s.strip.empty?
|
|
24
|
+
|
|
25
|
+
puts Mcp::Platform::NO_APP_ID
|
|
26
|
+
exit 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ensure_authenticated!
|
|
30
|
+
return if Credentials.token
|
|
31
|
+
|
|
32
|
+
puts Mcp::Platform::NOT_LOGGED_IN
|
|
33
|
+
exit 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def finish(outcome)
|
|
37
|
+
puts outcome.text
|
|
38
|
+
exit 1 unless outcome.ok
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
PLATFORM_LABELS = { "ios" => "iOS", "android" => "Android" }.freeze
|
|
42
|
+
|
|
43
|
+
# Every store the app has started, unless --ios or --android names one.
|
|
44
|
+
# A server too old to say, or an app with neither started, means iOS.
|
|
45
|
+
def platforms!(app_id)
|
|
46
|
+
return [ "android" ] if @argv.include?("--android")
|
|
47
|
+
return [ "ios" ] if @argv.include?("--ios")
|
|
48
|
+
|
|
49
|
+
status = Status.fetch(app_id)
|
|
50
|
+
started = status.ok? ? Array(status.value["platforms"]) & PLATFORM_LABELS.keys : []
|
|
51
|
+
started.empty? ? [ "ios" ] : started
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# One store prints as it always has; two get a heading each, and the
|
|
55
|
+
# command fails if either did.
|
|
56
|
+
def finish_each(platforms)
|
|
57
|
+
return finish(yield(platforms.first)) if platforms.one?
|
|
58
|
+
|
|
59
|
+
outcomes = platforms.map { |platform| [ platform, yield(platform) ] }
|
|
60
|
+
puts outcomes.map { |platform, outcome| "#{PLATFORM_LABELS[platform]}:\n#{outcome.text}" }.join("\n\n")
|
|
61
|
+
exit 1 unless outcomes.all? { |_, outcome| outcome.ok }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def positional
|
|
65
|
+
@argv.reject { |arg| arg.start_with?("--") }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def flag(name)
|
|
69
|
+
value = @argv.find { |arg| arg.start_with?("--#{name}=") }
|
|
70
|
+
value&.split("=", 2)&.last
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.failure(result)
|
|
74
|
+
lines = [ result.error ]
|
|
75
|
+
result.field_errors.each do |field, messages|
|
|
76
|
+
Array(messages).each do |message|
|
|
77
|
+
next if result.error.to_s.downcase.include?(message.to_s.downcase)
|
|
78
|
+
|
|
79
|
+
lines << " #{field} #{message}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
if result.blocked_by.any?
|
|
83
|
+
lines << ""
|
|
84
|
+
lines.concat(Status.blocker_lines(result.blocked_by))
|
|
85
|
+
end
|
|
86
|
+
Outcome.new(ok: false, text: lines.join("\n").strip, data: { "error" => result.error, "blocked_by" => result.blocked_by, "errors" => result.field_errors })
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.success(text, data)
|
|
90
|
+
Outcome.new(ok: true, text: text, data: data)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require "ruby_native/cli/command"
|
|
2
|
+
|
|
3
|
+
module RubyNative
|
|
4
|
+
class CLI
|
|
5
|
+
# The reviewer's sign-in, staged with rubynative.com and written to App
|
|
6
|
+
# Store Connect when the listing is published. It stays out of
|
|
7
|
+
# config/ruby_native_listing.yml on purpose: that file is committed.
|
|
8
|
+
class DemoAccount < Command
|
|
9
|
+
def run
|
|
10
|
+
ensure_authenticated!
|
|
11
|
+
app_id = app_id!
|
|
12
|
+
|
|
13
|
+
unless positional.first == "set" && flag("name")
|
|
14
|
+
puts "Usage: ruby_native demo_account set --name=EMAIL --password=SECRET [--notes=\"...\"] [--not-required]"
|
|
15
|
+
exit 1
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
finish(self.class.set(app_id, name: flag("name"), password: flag("password"), notes: flag("notes"), required: !@argv.include?("--not-required")))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.set(app_id, name:, password: nil, notes: nil, required: true)
|
|
22
|
+
demo = { name: name, required: required }
|
|
23
|
+
demo[:password] = password unless password.nil?
|
|
24
|
+
demo[:notes] = notes unless notes.nil?
|
|
25
|
+
|
|
26
|
+
result = Mcp::Platform.patch("/api/v1/apps/#{app_id}/listing", { demo_account: demo })
|
|
27
|
+
return failure(result) unless result.ok?
|
|
28
|
+
|
|
29
|
+
account = result.value["demo_account"] || {}
|
|
30
|
+
text = "Demo account #{account["name"]}#{account["password_set"] ? "" : " (no password set)"} saved. " \
|
|
31
|
+
"It reaches App Store Connect when the listing is published."
|
|
32
|
+
success(text, result.value)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -4,6 +4,8 @@ require "uri"
|
|
|
4
4
|
require "openssl"
|
|
5
5
|
require "ruby_native/cli/check"
|
|
6
6
|
require "ruby_native/cli/credentials"
|
|
7
|
+
require "ruby_native/cli/mcp/platform"
|
|
8
|
+
require "ruby_native/cli/status"
|
|
7
9
|
require "ruby_native/version"
|
|
8
10
|
|
|
9
11
|
module RubyNative
|
|
@@ -34,6 +36,8 @@ module RubyNative
|
|
|
34
36
|
@if_needed = argv.include?("--if-needed")
|
|
35
37
|
@skip_check = argv.include?("--skip-check")
|
|
36
38
|
@platform = parse_platform(argv)
|
|
39
|
+
@app_flag = argv.find { |arg| arg.start_with?("--app=") }&.split("=", 2)&.last
|
|
40
|
+
@version = argv.find { |arg| arg.start_with?("--version=") }&.split("=", 2)&.last
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
def run
|
|
@@ -46,7 +50,10 @@ module RubyNative
|
|
|
46
50
|
return
|
|
47
51
|
end
|
|
48
52
|
|
|
49
|
-
|
|
53
|
+
unless @skip_check
|
|
54
|
+
check_signals!
|
|
55
|
+
warn_scan_without_camera(app_id)
|
|
56
|
+
end
|
|
50
57
|
|
|
51
58
|
build = trigger_build(app_id)
|
|
52
59
|
return if @if_needed
|
|
@@ -83,6 +90,17 @@ module RubyNative
|
|
|
83
90
|
exit 1
|
|
84
91
|
end
|
|
85
92
|
|
|
93
|
+
# A warning, not a stop: the build is fine, one button in it isn't.
|
|
94
|
+
def warn_scan_without_camera(app_id)
|
|
95
|
+
file, line = Permissions.scan_without_camera(app_id)
|
|
96
|
+
return unless file
|
|
97
|
+
|
|
98
|
+
puts "Heads up: #{Permissions.scan_without_camera_message(file, line)}"
|
|
99
|
+
puts
|
|
100
|
+
rescue StandardError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
86
104
|
def ensure_authenticated!
|
|
87
105
|
return if Credentials.token
|
|
88
106
|
|
|
@@ -145,7 +163,7 @@ module RubyNative
|
|
|
145
163
|
end
|
|
146
164
|
|
|
147
165
|
def resolve_app_id!
|
|
148
|
-
app_id = @config.dig(:ruby_native, :app_id)
|
|
166
|
+
app_id = (@app_flag unless @app_flag.to_s.strip.empty?) || @config.dig(:ruby_native, :app_id)
|
|
149
167
|
app_id = link_app unless app_id
|
|
150
168
|
unless app_id
|
|
151
169
|
puts "No app selected. Run `ruby_native deploy` again."
|
|
@@ -244,6 +262,7 @@ module RubyNative
|
|
|
244
262
|
when Net::HTTPUnprocessableEntity
|
|
245
263
|
data = parse_json(response)
|
|
246
264
|
puts "Cannot build: #{data&.dig("error") || "the server rejected the request (422)."}"
|
|
265
|
+
print_blockers(data)
|
|
247
266
|
exit 1
|
|
248
267
|
when Net::HTTPNotFound
|
|
249
268
|
puts "The app linked in config/ruby_native.yml was not found on your account."
|
|
@@ -419,6 +438,16 @@ module RubyNative
|
|
|
419
438
|
|
|
420
439
|
# Server-provided warnings (a lapsed payment, say) reach customers with
|
|
421
440
|
# no gem release. Deduped, since every status poll repeats the string.
|
|
441
|
+
# A refusal names what is in the way and who clears it, the same list the
|
|
442
|
+
# dashboard shows. An older server sends only the sentence.
|
|
443
|
+
def print_blockers(data)
|
|
444
|
+
blockers = data.is_a?(Hash) ? data["blocked_by"] : nil
|
|
445
|
+
return unless blockers.is_a?(Array) && blockers.any?
|
|
446
|
+
|
|
447
|
+
puts ""
|
|
448
|
+
puts Status.blocker_lines(blockers)
|
|
449
|
+
end
|
|
450
|
+
|
|
422
451
|
def print_notice(data)
|
|
423
452
|
notice = data["notice"]
|
|
424
453
|
return unless notice.is_a?(String) && !notice.strip.empty?
|
|
@@ -483,6 +512,7 @@ module RubyNative
|
|
|
483
512
|
def build_payload
|
|
484
513
|
payload = { gem_version: RubyNative::VERSION }
|
|
485
514
|
payload[:platform] = @platform unless @platform == "ios"
|
|
515
|
+
payload[:version] = @version if @version && !@version.empty?
|
|
486
516
|
payload
|
|
487
517
|
end
|
|
488
518
|
|
|
@@ -538,16 +568,7 @@ module RubyNative
|
|
|
538
568
|
end
|
|
539
569
|
|
|
540
570
|
def write_app_id_to_config(app_id)
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
if raw.match?(/^ruby_native:/)
|
|
544
|
-
raw = raw.gsub(/^(ruby_native:\s*\n)/, "\\1 app_id: #{app_id}\n")
|
|
545
|
-
else
|
|
546
|
-
raw = raw.rstrip + "\n\nruby_native:\n app_id: #{app_id}\n"
|
|
547
|
-
end
|
|
548
|
-
|
|
549
|
-
File.write(CONFIG_PATH, raw)
|
|
550
|
-
|
|
571
|
+
Mcp::Platform.write_app_id(app_id, CONFIG_PATH)
|
|
551
572
|
@config = read_config
|
|
552
573
|
end
|
|
553
574
|
|
|
@@ -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
|