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,546 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
require "yaml"
|
|
3
|
+
|
|
4
|
+
module RubyNative
|
|
5
|
+
class CLI
|
|
6
|
+
class Mcp
|
|
7
|
+
# Static validation of config/ruby_native.yml.
|
|
8
|
+
#
|
|
9
|
+
# Every rule mirrors something that already happens at runtime: a value
|
|
10
|
+
# `RubyNative.load_config` normalizes away with a log line nobody reads,
|
|
11
|
+
# or one that fails the config decode on device and drops the user on the
|
|
12
|
+
# error screen instead of the app. The severities follow that split --
|
|
13
|
+
# `error` means the app does not boot, `warning` means the setting is
|
|
14
|
+
# quietly ignored.
|
|
15
|
+
#
|
|
16
|
+
# Rails is never booted: the CLI runs outside it, and the point is to
|
|
17
|
+
# answer before a server does.
|
|
18
|
+
class ConfigValidator
|
|
19
|
+
DEFAULT_PATH = "config/ruby_native.yml".freeze
|
|
20
|
+
|
|
21
|
+
# The whole top-level vocabulary. `app`, `appearance`, `tabs`, `auth`,
|
|
22
|
+
# `errors`, `analytics`, and `android.firebase` are decoded on device;
|
|
23
|
+
# `ios`, the rest of `android`, and `linked_paths` are served as
|
|
24
|
+
# universal-link metadata by the engine; `ruby_native` holds the app_id
|
|
25
|
+
# `deploy` writes.
|
|
26
|
+
TOP_LEVEL_KEYS = %w[app appearance tabs auth errors analytics ios android linked_paths ruby_native].freeze
|
|
27
|
+
APP_KEYS = %w[name mode entry_path].freeze
|
|
28
|
+
# `android` is the per-platform chrome block, read only by the Android app.
|
|
29
|
+
APPEARANCE_KEYS = %w[tint_color background_color theme landscape navbar splash android].freeze
|
|
30
|
+
NAVBAR_KEYS = %w[logo background_color foreground_color status_bar].freeze
|
|
31
|
+
SPLASH_KEYS = %w[enabled spinner_color status_bar].freeze
|
|
32
|
+
TAB_KEYS = %w[title titles path icon icons key eager search badge auto_route].freeze
|
|
33
|
+
ANDROID_KEYS = %w[package cert_fingerprint firebase].freeze
|
|
34
|
+
FIREBASE_KEYS = %w[app_id api_key project_id sender_id].freeze
|
|
35
|
+
PUSH_DOCS = "https://rubynative.com/docs/android/push-notifications".freeze
|
|
36
|
+
|
|
37
|
+
MODES = %w[normal advanced].freeze
|
|
38
|
+
THEMES = %w[auto light dark].freeze
|
|
39
|
+
STATUS_BARS = %w[light dark].freeze
|
|
40
|
+
|
|
41
|
+
# UIColor(hex:) takes exactly six hex digits and an optional `#`. A
|
|
42
|
+
# three-digit shorthand parses as nil, which reads on device as the
|
|
43
|
+
# color simply not applying.
|
|
44
|
+
HEX_COLOR = /\A#?\h{6}\z/
|
|
45
|
+
|
|
46
|
+
# The file is rendered as ERB before Rails parses it, so a navbar logo
|
|
47
|
+
# can interpolate `image_url`. There is no Rails here to render against,
|
|
48
|
+
# so tags become a sentinel: the document stays valid YAML, and no rule
|
|
49
|
+
# fires on a value this file cannot know.
|
|
50
|
+
ERB_TAG = /<%=?-?.*?-?%>/m
|
|
51
|
+
ERB_SENTINEL = "ruby-native-erb-value".freeze
|
|
52
|
+
|
|
53
|
+
# Generated by `script/icon_names`. A name missing here draws the
|
|
54
|
+
# placeholder on device, so these are warnings: the app still boots.
|
|
55
|
+
ICONS_DIR = File.expand_path("../../icons", __dir__)
|
|
56
|
+
IOS_FLOOR = Gem::Version.new("16.0")
|
|
57
|
+
ICON_DOCS = "https://rubynative.com/docs/icons".freeze
|
|
58
|
+
|
|
59
|
+
Offense = Struct.new(:key, :line, :severity, :message, keyword_init: true)
|
|
60
|
+
|
|
61
|
+
def self.run(path: DEFAULT_PATH)
|
|
62
|
+
new(path).run
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.sf_symbols
|
|
66
|
+
@sf_symbols ||= File.readlines("#{ICONS_DIR}/sf_symbols.tsv", chomp: true)
|
|
67
|
+
.to_h { |line| line.split("\t") }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.material_symbols
|
|
71
|
+
@material_symbols ||= File.readlines("#{ICONS_DIR}/material_symbols.txt", chomp: true).to_set
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def initialize(path = DEFAULT_PATH)
|
|
75
|
+
@path = path
|
|
76
|
+
@offenses = []
|
|
77
|
+
@lines = []
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def run
|
|
81
|
+
unless File.exist?(@path)
|
|
82
|
+
return [Offense.new(
|
|
83
|
+
key: nil,
|
|
84
|
+
line: nil,
|
|
85
|
+
severity: :error,
|
|
86
|
+
message: "#{@path} does not exist. Run `rails generate ruby_native:install` to create it."
|
|
87
|
+
)]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
source = File.read(@path, encoding: "UTF-8")
|
|
91
|
+
@lines = source.lines
|
|
92
|
+
@erb = source.match?(ERB_TAG)
|
|
93
|
+
|
|
94
|
+
config = parse(source)
|
|
95
|
+
return @offenses if @parse_failed
|
|
96
|
+
|
|
97
|
+
unless config.is_a?(Hash)
|
|
98
|
+
error(nil, "#{@path} is empty or is not a YAML mapping, so Ruby Native ignores the whole file and the app boots unconfigured.")
|
|
99
|
+
return @offenses
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
check_top_level(config)
|
|
103
|
+
check_app(config["app"])
|
|
104
|
+
check_appearance(config["appearance"])
|
|
105
|
+
check_tabs(config["tabs"])
|
|
106
|
+
check_auth(config["auth"])
|
|
107
|
+
check_analytics(config)
|
|
108
|
+
check_linked_paths(config["linked_paths"])
|
|
109
|
+
check_identifiers(config["ios"], "ios", %w[bundle_id team_id], "/.well-known/apple-app-site-association")
|
|
110
|
+
check_android(config["android"])
|
|
111
|
+
|
|
112
|
+
@offenses
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def parse(source)
|
|
118
|
+
YAML.load(source.gsub(ERB_TAG, ERB_SENTINEL), aliases: true)
|
|
119
|
+
rescue Psych::SyntaxError => syntax_error
|
|
120
|
+
@parse_failed = true
|
|
121
|
+
|
|
122
|
+
# Stubbing out `<%= %>` keeps a value-position tag valid, but a tag
|
|
123
|
+
# that opens a block takes structure with it. That is this command's
|
|
124
|
+
# blind spot, not the developer's mistake, so it says so rather than
|
|
125
|
+
# reporting a syntax error the file does not have.
|
|
126
|
+
if @erb
|
|
127
|
+
warning(nil, "Could not check #{@path}: stubbing out its ERB left YAML that does not parse (#{syntax_error.problem}). " \
|
|
128
|
+
"That happens when a tag spans structure rather than filling in one value, and it does not mean the file is broken.",
|
|
129
|
+
line: syntax_line(syntax_error))
|
|
130
|
+
else
|
|
131
|
+
error(nil, "#{@path} is not valid YAML: #{syntax_error.problem}. Rails cannot load it, so the app has no config at all.",
|
|
132
|
+
line: syntax_line(syntax_error))
|
|
133
|
+
end
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# libyaml counts lines from zero and reports where it gave up, which can
|
|
138
|
+
# be the line after the mistake or the end of the file. Adding one and
|
|
139
|
+
# clamping to the file puts every shape of that back on a real line.
|
|
140
|
+
def syntax_line(syntax_error)
|
|
141
|
+
[[syntax_error.line + 1, @lines.size].min, 1].max
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# --- Sections ---
|
|
145
|
+
|
|
146
|
+
def check_top_level(config)
|
|
147
|
+
config.each_key do |key|
|
|
148
|
+
next if TOP_LEVEL_KEYS.include?(key.to_s)
|
|
149
|
+
|
|
150
|
+
warning(key.to_s, "Unknown top-level key `#{key}`, which Ruby Native ignores. It reads: #{TOP_LEVEL_KEYS.join(", ")}.")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def check_app(app)
|
|
155
|
+
return if app.nil?
|
|
156
|
+
return error("app", "`app` has to be a mapping.") unless app.is_a?(Hash)
|
|
157
|
+
|
|
158
|
+
unknown_keys(app, APP_KEYS, "app")
|
|
159
|
+
|
|
160
|
+
mode = app["mode"]
|
|
161
|
+
if mode && !sentinel?(mode) && !MODES.include?(mode.to_s)
|
|
162
|
+
warning("app.mode", "`app.mode` is #{mode.inspect}. The app matches it against #{MODES.join(" and ")} and runs Normal Mode for anything else, so a typo here is silent.")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
entry = app["entry_path"]
|
|
166
|
+
if entry.is_a?(String) && !sentinel?(entry) && !entry.start_with?("/")
|
|
167
|
+
warning("app.entry_path", "`app.entry_path` is #{entry.inspect}. It is a path the app opens on launch, so write it absolute: \"/#{entry}\".")
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def check_appearance(appearance)
|
|
172
|
+
return if appearance.nil?
|
|
173
|
+
return error("appearance", "`appearance` has to be a mapping.") unless appearance.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
unknown_keys(appearance, APPEARANCE_KEYS, "appearance")
|
|
176
|
+
check_color(appearance["tint_color"], "appearance.tint_color")
|
|
177
|
+
check_color(appearance["background_color"], "appearance.background_color")
|
|
178
|
+
check_enum(appearance["theme"], "appearance.theme", THEMES)
|
|
179
|
+
check_boolean(appearance["landscape"], "appearance.landscape")
|
|
180
|
+
check_navbar(appearance["navbar"])
|
|
181
|
+
check_splash(appearance["splash"])
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def check_navbar(navbar)
|
|
185
|
+
return if navbar.nil?
|
|
186
|
+
return error("appearance.navbar", "`appearance.navbar` has to be a mapping.") unless navbar.is_a?(Hash)
|
|
187
|
+
|
|
188
|
+
unknown_keys(navbar, NAVBAR_KEYS, "appearance.navbar")
|
|
189
|
+
check_color(navbar["background_color"], "appearance.navbar.background_color")
|
|
190
|
+
check_color(navbar["foreground_color"], "appearance.navbar.foreground_color")
|
|
191
|
+
check_enum(navbar["status_bar"], "appearance.navbar.status_bar", STATUS_BARS)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def check_splash(splash)
|
|
195
|
+
return if splash.nil?
|
|
196
|
+
return error("appearance.splash", "`appearance.splash` has to be a mapping.") unless splash.is_a?(Hash)
|
|
197
|
+
|
|
198
|
+
unknown_keys(splash, SPLASH_KEYS, "appearance.splash")
|
|
199
|
+
check_boolean(splash["enabled"], "appearance.splash.enabled")
|
|
200
|
+
check_color(splash["spinner_color"], "appearance.splash.spinner_color")
|
|
201
|
+
check_enum(splash["status_bar"], "appearance.splash.status_bar", STATUS_BARS)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def check_tabs(tabs)
|
|
205
|
+
return if tabs.nil?
|
|
206
|
+
return error("tabs", "`tabs` has to be a list of tabs.") unless tabs.is_a?(Array)
|
|
207
|
+
|
|
208
|
+
keys = []
|
|
209
|
+
|
|
210
|
+
tabs.each_with_index do |tab, index|
|
|
211
|
+
location = "tabs[#{index}]"
|
|
212
|
+
|
|
213
|
+
unless tab.is_a?(Hash)
|
|
214
|
+
error(location, "`#{location}` has to be a mapping with a title, a path, and an icon.")
|
|
215
|
+
next
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
unknown_keys(tab, TAB_KEYS, location)
|
|
219
|
+
check_tab_path(tab, location)
|
|
220
|
+
check_tab_icon(tab, location)
|
|
221
|
+
check_tab_icon_names(tab, location)
|
|
222
|
+
check_tab_title(tab, location)
|
|
223
|
+
%w[eager search badge].each { |flag| check_boolean(tab[flag], "#{location}.#{flag}") }
|
|
224
|
+
check_auto_route(tab["auto_route"], "#{location}.auto_route")
|
|
225
|
+
|
|
226
|
+
keys << tab["key"].to_s if tab["key"]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
duplicate_keys(keys)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def check_tab_path(tab, location)
|
|
233
|
+
path = tab["path"]
|
|
234
|
+
|
|
235
|
+
if path.nil?
|
|
236
|
+
error("#{location}.path", "`#{location}` has no `path`. Every tab needs one, and without it the whole config fails to decode: the app shows its error screen instead of your tabs.")
|
|
237
|
+
elsif path.is_a?(String) && !sentinel?(path) && !path.start_with?("/")
|
|
238
|
+
warning("#{location}.path", "`#{location}.path` is #{path.inspect}. Tab paths are absolute, so write it as \"/#{path}\".")
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# `icon` is a required String on both platforms. The gem backfills it
|
|
243
|
+
# from `icons.ios` or `icons.android`, so either form satisfies it.
|
|
244
|
+
def check_tab_icon(tab, location)
|
|
245
|
+
icons = tab["icons"]
|
|
246
|
+
return if tab["icon"].is_a?(String) && !tab["icon"].empty?
|
|
247
|
+
return if icons.is_a?(Hash) && (icons["ios"] || icons["android"])
|
|
248
|
+
|
|
249
|
+
error("#{location}.icon", "`#{location}` has no icon. Give it `icon:`, or `icons:` with `ios:` or `android:`. Without one the whole config fails to decode and the app shows its error screen. Icon names: #{ICON_DOCS}")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Each platform reads its own `icons:` entry first, then the flat `icon:`.
|
|
253
|
+
def check_tab_icon_names(tab, location)
|
|
254
|
+
icons = tab["icons"].is_a?(Hash) ? tab["icons"] : {}
|
|
255
|
+
flat = tab["icon"] if tab["icon"].is_a?(String)
|
|
256
|
+
|
|
257
|
+
if icons["ios"].is_a?(String)
|
|
258
|
+
check_sf_symbol(icons["ios"], "#{location}.icons.ios")
|
|
259
|
+
elsif flat
|
|
260
|
+
check_sf_symbol(flat, "#{location}.icon", shared: true)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
if icons["android"].is_a?(String)
|
|
264
|
+
check_material_symbol(icons["android"], "#{location}.icons.android")
|
|
265
|
+
elsif flat
|
|
266
|
+
check_material_symbol(flat, "#{location}.icon", shared: true)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def check_sf_symbol(name, key, shared: false)
|
|
271
|
+
return if name.empty? || sentinel?(name)
|
|
272
|
+
|
|
273
|
+
added = self.class.sf_symbols[name]
|
|
274
|
+
|
|
275
|
+
if added.nil?
|
|
276
|
+
hint = if shared && self.class.material_symbols.include?(name)
|
|
277
|
+
" It looks like a Material Symbols name, so move it to `icons.android` and give `icons.ios` an SF Symbol."
|
|
278
|
+
end
|
|
279
|
+
warning(key, "`#{key}` is #{name.inspect}, which isn't an SF Symbol, so iOS draws a placeholder instead.#{hint} Icon names: #{ICON_DOCS}")
|
|
280
|
+
elsif Gem::Version.new(added) > IOS_FLOOR
|
|
281
|
+
warning(key, "`#{key}` is #{name.inspect}, which needs iOS #{added}. Ruby Native apps run on iOS #{IOS_FLOOR.segments.first} and up, so older devices draw a placeholder instead. Pick a symbol that's been around longer, or accept the placeholder on older devices.")
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def check_material_symbol(name, key, shared: false)
|
|
286
|
+
return if name.empty? || sentinel?(name)
|
|
287
|
+
return if self.class.material_symbols.include?(name)
|
|
288
|
+
|
|
289
|
+
hint = if shared && self.class.sf_symbols.key?(name)
|
|
290
|
+
" That name works on iOS, so keep it under `icons.ios` and give `icons.android` a Material Symbols name."
|
|
291
|
+
end
|
|
292
|
+
warning(key, "`#{key}` is #{name.inspect}, which isn't a Material Symbol, so Android draws a placeholder instead.#{hint} Icon names: #{ICON_DOCS}")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# `title` is optional in YAML only because locale files can carry it.
|
|
296
|
+
# With neither, the gem names the tab after its key, or "Untitled".
|
|
297
|
+
def check_tab_title(tab, location)
|
|
298
|
+
return if tab["title"].is_a?(String) && !tab["title"].empty?
|
|
299
|
+
|
|
300
|
+
if tab["key"]
|
|
301
|
+
warning("#{location}.title", "`#{location}` has no `title`, so its label comes from `ruby_native.tabs.#{tab["key"]}.title` in your locale files. Make sure that key exists, or the tab falls back to a title built from #{tab["key"].to_s.inspect}.")
|
|
302
|
+
else
|
|
303
|
+
warning("#{location}.title", "`#{location}` has no `title` and no `key`, so the tab renders as \"Untitled\". Give it a title, or a key with copy under `ruby_native.tabs.<key>.title`.")
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def check_auto_route(value, key)
|
|
308
|
+
return if value.nil? || value == false || value.is_a?(Array)
|
|
309
|
+
|
|
310
|
+
if value == true
|
|
311
|
+
error(key, "`#{key}` is true, which the app rejects outright: the config fails to decode and the user gets the error screen. Use false to never switch to this tab, or a list of path prefixes.")
|
|
312
|
+
else
|
|
313
|
+
error(key, "`#{key}` is #{value.inspect}. It takes false, or a list of path prefixes like [\"/orders/\"]. Anything else fails the config decode.")
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def check_auth(auth)
|
|
318
|
+
return if auth.nil?
|
|
319
|
+
return error("auth", "`auth` has to be a mapping.") unless auth.is_a?(Hash)
|
|
320
|
+
|
|
321
|
+
paths = auth["oauth_paths"]
|
|
322
|
+
return if paths.nil?
|
|
323
|
+
|
|
324
|
+
unless paths.is_a?(Array)
|
|
325
|
+
warning("auth.oauth_paths", "`auth.oauth_paths` is a single value. Ruby Native reads it as a list of one, but write it as a list.")
|
|
326
|
+
return
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
paths.select { |path| paths.any? { |start| path == "#{start}/callback" } }.each do |callback|
|
|
330
|
+
warning("auth.oauth_paths", "`auth.oauth_paths` lists #{callback.inspect}. List only the authorize path: Ruby Native drops callbacks, and the app would otherwise read the last segment as a provider named \"callback\" and loop sign-in.")
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def check_analytics(config)
|
|
335
|
+
return unless config.key?("analytics")
|
|
336
|
+
|
|
337
|
+
value = config["analytics"]
|
|
338
|
+
return if value == true || value == false
|
|
339
|
+
|
|
340
|
+
warning("analytics", "`analytics` is #{value.inspect}. It has to be true or false; Ruby Native drops anything else, including the string \"false\", and keeps reporting.")
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def check_linked_paths(paths)
|
|
344
|
+
return if paths.nil?
|
|
345
|
+
return error("linked_paths", "`linked_paths` has to be a list of path prefixes.") unless paths.is_a?(Array)
|
|
346
|
+
|
|
347
|
+
paths.each do |path|
|
|
348
|
+
text = path.to_s
|
|
349
|
+
|
|
350
|
+
if text.end_with?("*")
|
|
351
|
+
warning("linked_paths", "`linked_paths` lists #{path.inspect}. Ruby Native strips the trailing `*` -- the AASA file uses one, this file takes a plain prefix -- so drop it here.")
|
|
352
|
+
elsif !text.start_with?("/")
|
|
353
|
+
warning("linked_paths", "`linked_paths` lists #{path.inspect}. Entries are absolute prefixes; Ruby Native rewrites it to \"/#{text}\".")
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def check_identifiers(section, key, required, endpoint)
|
|
359
|
+
return if section.nil?
|
|
360
|
+
return error(key, "`#{key}` has to be a mapping.") unless section.is_a?(Hash)
|
|
361
|
+
|
|
362
|
+
missing = required.select { |field| section[field].to_s.strip.empty? }
|
|
363
|
+
return if missing.empty?
|
|
364
|
+
|
|
365
|
+
warning(key, "`#{key}` is missing #{missing.map { |field| "`#{field}`" }.join(" and ")}. Your app serves #{endpoint} only once both are set, so universal links and shared passwords stay off until then.")
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def check_android(android)
|
|
369
|
+
return if android.nil?
|
|
370
|
+
return error("android", "`android` has to be a mapping.") unless android.is_a?(Hash)
|
|
371
|
+
|
|
372
|
+
unknown_keys(android, ANDROID_KEYS, "android")
|
|
373
|
+
if android.key?("package") || android.key?("cert_fingerprint")
|
|
374
|
+
check_identifiers(android, "android", %w[package cert_fingerprint], "/.well-known/assetlinks.json")
|
|
375
|
+
end
|
|
376
|
+
check_firebase(android["firebase"])
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def check_firebase(firebase)
|
|
380
|
+
return if firebase.nil?
|
|
381
|
+
return warning("android.firebase", "`android.firebase` has to be a mapping with #{FIREBASE_KEYS.join(", ")}. Until it is, push stays off on Android. #{PUSH_DOCS}") unless firebase.is_a?(Hash)
|
|
382
|
+
|
|
383
|
+
unknown_keys(firebase, FIREBASE_KEYS, "android.firebase")
|
|
384
|
+
missing = FIREBASE_KEYS.select { |field| firebase[field].to_s.strip.empty? }
|
|
385
|
+
if missing.any?
|
|
386
|
+
warning("android.firebase", "`android.firebase` is missing #{missing.map { |field| "`#{field}`" }.join(" and ")}. The app needs all four to start Firebase, so push stays off on Android until then. #{PUSH_DOCS}")
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
app_id = firebase["app_id"]
|
|
390
|
+
if app_id.is_a?(String) && !app_id.empty? && !sentinel?(app_id) && !app_id.include?(":android:")
|
|
391
|
+
warning("android.firebase.app_id", "`android.firebase.app_id` is #{app_id.inspect}, which is not an Android app id. Copy `mobilesdk_app_id` from google-services.json; it looks like 1:1234567890:android:abc123.")
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# --- Value checks ---
|
|
396
|
+
|
|
397
|
+
def check_color(value, key)
|
|
398
|
+
return if value.nil?
|
|
399
|
+
|
|
400
|
+
case value
|
|
401
|
+
when String
|
|
402
|
+
return if sentinel?(value) || value.match?(HEX_COLOR)
|
|
403
|
+
|
|
404
|
+
warning(key, "`#{key}` is #{value.inspect}, which is not a six-digit hex color. The app cannot read it and falls back to its default, with nothing said either way. Write it as \"#RRGGBB\".")
|
|
405
|
+
when Hash
|
|
406
|
+
missing = %w[light dark] - value.keys.map(&:to_s)
|
|
407
|
+
|
|
408
|
+
if missing.any?
|
|
409
|
+
error(key, "`#{key}` is a mapping without #{missing.map { |field| "`#{field}`" }.join(" and ")}. A per-theme color needs both, or the config fails to decode and the app shows its error screen.")
|
|
410
|
+
else
|
|
411
|
+
check_color(value["light"], "#{key}.light")
|
|
412
|
+
check_color(value["dark"], "#{key}.dark")
|
|
413
|
+
end
|
|
414
|
+
else
|
|
415
|
+
error(key, "`#{key}` is #{value.inspect}. A color is a hex string like \"#007AFF\", or a mapping with `light` and `dark`. Anything else fails the config decode and the app shows its error screen.")
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def check_boolean(value, key)
|
|
420
|
+
return if value.nil? || value == true || value == false
|
|
421
|
+
|
|
422
|
+
error(key, "`#{key}` is #{value.inspect}. It has to be true or false, or the config fails to decode and the app shows its error screen.")
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def check_enum(value, key, allowed)
|
|
426
|
+
return if value.nil? || sentinel?(value)
|
|
427
|
+
return if allowed.include?(value.to_s)
|
|
428
|
+
|
|
429
|
+
error(key, "`#{key}` is #{value.inspect}. It takes #{allowed.join(", ")}; anything else fails the config decode and the app shows its error screen.")
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def unknown_keys(section, known, prefix)
|
|
433
|
+
section.each_key do |key|
|
|
434
|
+
next if known.include?(key.to_s)
|
|
435
|
+
|
|
436
|
+
warning("#{prefix}.#{key}", "Unknown key `#{key}` under `#{prefix}`, which Ruby Native ignores. It reads: #{known.join(", ")}.")
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# A tab's key names its copy in the host app's locale files, so two tabs
|
|
441
|
+
# sharing one read the same translations and one is always mislabeled.
|
|
442
|
+
def duplicate_keys(keys)
|
|
443
|
+
keys.tally.select { |_, count| count > 1 }.each_key do |key|
|
|
444
|
+
warning("tabs", "Two or more tabs share the key #{key.inspect}, so they read the same translations and one of them is always mislabeled. Give each tab its own key.")
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# --- Offenses ---
|
|
449
|
+
|
|
450
|
+
def error(key, message, line: nil)
|
|
451
|
+
add(:error, key, message, line)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def warning(key, message, line: nil)
|
|
455
|
+
add(:warning, key, message, line)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def add(severity, key, message, line)
|
|
459
|
+
@offenses << Offense.new(key: key, line: line || line_for(key), severity: severity, message: message)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Walks the file the way the key path walks the document: `tabs[1].icon`
|
|
463
|
+
# finds `tabs:`, then the second list item under it, then `icon:` inside
|
|
464
|
+
# that item. A missing leaf falls back to the block that should have
|
|
465
|
+
# held it, which is where the fix goes. Every other dead end returns
|
|
466
|
+
# nil: a wrong line number sends a reader somewhere else entirely,
|
|
467
|
+
# which is worse than sending them nowhere.
|
|
468
|
+
def line_for(key)
|
|
469
|
+
return nil if key.nil?
|
|
470
|
+
|
|
471
|
+
segments = key.to_s.split(".")
|
|
472
|
+
first = 0
|
|
473
|
+
last = @lines.size
|
|
474
|
+
line = nil
|
|
475
|
+
|
|
476
|
+
segments.each_with_index do |segment, position|
|
|
477
|
+
name, index = segment.match(/\A([^\[]*)(?:\[(\d+)\])?\z/).captures
|
|
478
|
+
|
|
479
|
+
unless name.empty?
|
|
480
|
+
found = find_key(name, first, last)
|
|
481
|
+
return position == segments.size - 1 && line ? line + 1 : nil unless found
|
|
482
|
+
|
|
483
|
+
line = found
|
|
484
|
+
first, last = block_bounds(found)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
if index
|
|
488
|
+
bounds = item_bounds(first, last, index.to_i)
|
|
489
|
+
return nil unless bounds
|
|
490
|
+
|
|
491
|
+
first, last = bounds
|
|
492
|
+
line = first
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
line ? line + 1 : nil
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# The first line in range holding `name:`, preferring the shallowest
|
|
500
|
+
# indentation so a direct child wins over one nested deeper. Comments
|
|
501
|
+
# never match, since the `#` lands where the key would.
|
|
502
|
+
def find_key(name, first, last)
|
|
503
|
+
pattern = /\A\s*(?:-\s+)?#{Regexp.escape(name)}\s*:/
|
|
504
|
+
|
|
505
|
+
(first...last)
|
|
506
|
+
.select { |index| @lines[index].match?(pattern) }
|
|
507
|
+
.min_by { |index| [indent(index), index] }
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# The lines a key owns: everything under it indented deeper than it is.
|
|
511
|
+
def block_bounds(line)
|
|
512
|
+
depth = indent(line)
|
|
513
|
+
finish = ((line + 1)...@lines.size).find { |index| meaningful?(index) && indent(index) <= depth }
|
|
514
|
+
|
|
515
|
+
[line + 1, finish || @lines.size]
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# The lines of the nth `- ` item in range, counting only items at the
|
|
519
|
+
# shallowest dash indentation so a nested list is not mistaken for one.
|
|
520
|
+
def item_bounds(first, last, wanted)
|
|
521
|
+
starts = (first...last).select { |index| @lines[index].match?(/\A\s*-\s/) }
|
|
522
|
+
return nil if starts.empty?
|
|
523
|
+
|
|
524
|
+
depth = starts.map { |index| indent(index) }.min
|
|
525
|
+
starts = starts.select { |index| indent(index) == depth }
|
|
526
|
+
start = starts[wanted]
|
|
527
|
+
return nil unless start
|
|
528
|
+
|
|
529
|
+
[start, starts[wanted + 1] || last]
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def indent(line)
|
|
533
|
+
@lines[line][/\A */].length
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def meaningful?(line)
|
|
537
|
+
!@lines[line].match?(/\A\s*(#|\z)/)
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def sentinel?(value)
|
|
541
|
+
value.is_a?(String) && value.include?(ERB_SENTINEL)
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module RubyNative
|
|
4
|
+
class CLI
|
|
5
|
+
class Mcp
|
|
6
|
+
# Registers the server in the project's `.mcp.json`, the file Claude Code
|
|
7
|
+
# reads for project-scoped servers, so "point your agent at it" is one
|
|
8
|
+
# command rather than a JSON snippet to paste. Anything already in the
|
|
9
|
+
# file is kept; only the ruby-native entry is written.
|
|
10
|
+
class Install
|
|
11
|
+
PATH = ".mcp.json".freeze
|
|
12
|
+
NAME = "ruby-native".freeze
|
|
13
|
+
SERVER = { "command" => "bundle", "args" => %w[exec ruby_native mcp] }.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(_argv = [])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
config = existing_config
|
|
20
|
+
servers = config["mcpServers"] = config.fetch("mcpServers", {})
|
|
21
|
+
already = servers[NAME] == SERVER
|
|
22
|
+
servers[NAME] = SERVER
|
|
23
|
+
|
|
24
|
+
File.write(PATH, JSON.pretty_generate(config) + "\n") unless already
|
|
25
|
+
|
|
26
|
+
puts already ? "#{PATH} already has the ruby-native server." : "Added the ruby-native server to #{PATH}."
|
|
27
|
+
puts "Claude Code picks it up on its next start in this directory."
|
|
28
|
+
puts ""
|
|
29
|
+
puts "Other clients take the same entry:"
|
|
30
|
+
puts JSON.pretty_generate("mcpServers" => { NAME => SERVER })
|
|
31
|
+
puts ""
|
|
32
|
+
puts "Then, in your agent:"
|
|
33
|
+
puts ""
|
|
34
|
+
puts " /mcp__#{NAME}__ship"
|
|
35
|
+
puts ""
|
|
36
|
+
puts "It walks every step to the App Store and Google Play and stops at the ones only you can take."
|
|
37
|
+
puts "No prompt support in your agent? Paste the output of: bundle exec ruby_native ship"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def existing_config
|
|
43
|
+
return {} unless File.exist?(PATH)
|
|
44
|
+
|
|
45
|
+
config = JSON.parse(File.read(PATH))
|
|
46
|
+
config.is_a?(Hash) ? config : {}
|
|
47
|
+
rescue JSON::ParserError
|
|
48
|
+
puts "#{PATH} is not valid JSON, so nothing was written. Fix or remove it and run this again."
|
|
49
|
+
exit 1
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|