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,1026 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "ruby_native/cli/apple"
|
|
3
|
+
require "ruby_native/cli/check"
|
|
4
|
+
require "ruby_native/cli/mcp/config_validator"
|
|
5
|
+
require "ruby_native/cli/mcp/platform"
|
|
6
|
+
require "ruby_native/cli/mcp/prompts"
|
|
7
|
+
require "ruby_native/cli/credentials"
|
|
8
|
+
require "ruby_native/cli/demo_account"
|
|
9
|
+
require "ruby_native/cli/icon"
|
|
10
|
+
require "ruby_native/cli/listing"
|
|
11
|
+
require "ruby_native/cli/permissions"
|
|
12
|
+
require "ruby_native/cli/screenshots"
|
|
13
|
+
require "ruby_native/cli/link"
|
|
14
|
+
require "ruby_native/cli/status"
|
|
15
|
+
require "ruby_native/cli/submit"
|
|
16
|
+
require "ruby_native/cli/testers"
|
|
17
|
+
require "ruby_native/signals"
|
|
18
|
+
require "ruby_native/version"
|
|
19
|
+
|
|
20
|
+
module RubyNative
|
|
21
|
+
class CLI
|
|
22
|
+
# An MCP server over stdin/stdout, so a coding agent working in a Rails app
|
|
23
|
+
# can check its own work before anyone builds it.
|
|
24
|
+
#
|
|
25
|
+
# Signals fail silently by design (see Check): a shell that does not
|
|
26
|
+
# understand one ignores it. A person notices the button that never
|
|
27
|
+
# appeared. An agent writing markup it will never see rendered does not,
|
|
28
|
+
# and neither does the reviewer reading a diff of plausible-looking
|
|
29
|
+
# attributes. These tools hand it the same answers `ruby_native check`
|
|
30
|
+
# gives a person, in a shape it can act on without a device.
|
|
31
|
+
#
|
|
32
|
+
# Read-only throughout, and local by default: check_views, lookup_signals,
|
|
33
|
+
# and validate_config answer from this working copy alone. Three more --
|
|
34
|
+
# config_errors, deployed_builds, and build_status -- read the Ruby Native
|
|
35
|
+
# API with the token `ruby_native login` already stored, because what they
|
|
36
|
+
# answer exists nowhere else: what real devices are failing on, and what
|
|
37
|
+
# the binary in the store is old enough to ignore. Nothing here writes,
|
|
38
|
+
# and nothing deploys -- an agent that can start an App Store build is a
|
|
39
|
+
# different feature with a different set of questions.
|
|
40
|
+
class Mcp
|
|
41
|
+
# The versions of the protocol this speaks. A client asking for one of
|
|
42
|
+
# them gets it back; anything else is answered with the newest, which is
|
|
43
|
+
# what the spec asks a server to do.
|
|
44
|
+
PROTOCOL_VERSIONS = %w[2024-11-05 2025-03-26 2025-06-18].freeze
|
|
45
|
+
PROTOCOL_VERSION = PROTOCOL_VERSIONS.last
|
|
46
|
+
|
|
47
|
+
PARSE_ERROR = -32700
|
|
48
|
+
INVALID_REQUEST = -32600
|
|
49
|
+
METHOD_NOT_FOUND = -32601
|
|
50
|
+
INVALID_PARAMS = -32602
|
|
51
|
+
|
|
52
|
+
# Raised by a tool that cannot run at all, as opposed to one that ran and
|
|
53
|
+
# found problems. Problems are the answer; these are reported as failures.
|
|
54
|
+
class ToolFailure < StandardError; end
|
|
55
|
+
|
|
56
|
+
# A method this server does not implement, which is a different answer
|
|
57
|
+
# from a call it understood and could not carry out.
|
|
58
|
+
class UnknownMethod < StandardError; end
|
|
59
|
+
|
|
60
|
+
TOOLS = [
|
|
61
|
+
{
|
|
62
|
+
name: "check_views",
|
|
63
|
+
description: "Check a Rails app's ERB templates against the Ruby Native signal vocabulary and report what " \
|
|
64
|
+
"the native app will silently ignore: misspelled data-native-* attributes, signals newer than " \
|
|
65
|
+
"the installed gem, and duplicates of a signal that is only read once. Run this after writing " \
|
|
66
|
+
"or editing any view that uses data-native-* attributes or native_* helpers -- these failures " \
|
|
67
|
+
"are invisible at runtime, so nothing else will catch them.",
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: {
|
|
71
|
+
paths: {
|
|
72
|
+
type: "array",
|
|
73
|
+
items: { type: "string" },
|
|
74
|
+
description: "Directories to scan, relative to the app root. Defaults to app/views."
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
additionalProperties: false
|
|
78
|
+
},
|
|
79
|
+
annotations: { title: "Check views", readOnlyHint: true, openWorldHint: false }
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "lookup_signals",
|
|
83
|
+
description: "Look up the Ruby Native signal vocabulary: every data-native-* attribute the native apps " \
|
|
84
|
+
"read, the gem version each one needs, and the helper that emits it. Call this before " \
|
|
85
|
+
"writing a data-native-* attribute rather than guessing at one, because an attribute the " \
|
|
86
|
+
"apps do not know is ignored without any error.",
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: "object",
|
|
89
|
+
properties: {
|
|
90
|
+
name: {
|
|
91
|
+
type: "string",
|
|
92
|
+
description: "One signal to look up, with or without the data-native- prefix. Omit to list them all."
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
additionalProperties: false
|
|
96
|
+
},
|
|
97
|
+
annotations: { title: "Look up signals", readOnlyHint: true, openWorldHint: false }
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "validate_config",
|
|
101
|
+
description: "Check config/ruby_native.yml against what the native apps actually decode. Reports errors " \
|
|
102
|
+
"that make the app fail to load its config and show an error screen instead of the app -- a " \
|
|
103
|
+
"tab without an icon, an unrecognized theme -- and warnings for settings that are silently " \
|
|
104
|
+
"ignored, like a three-digit hex color or a misspelled key. Run this after editing the file.",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: {
|
|
108
|
+
path: {
|
|
109
|
+
type: "string",
|
|
110
|
+
description: "Path to the config file. Defaults to config/ruby_native.yml."
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
additionalProperties: false
|
|
114
|
+
},
|
|
115
|
+
annotations: { title: "Validate config", readOnlyHint: true, openWorldHint: false }
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: "config_errors",
|
|
119
|
+
description: "Report what the app your users are running is failing on: the config errors real devices " \
|
|
120
|
+
"sent back over the last 48 hours, each with the decode failure that caused it. This is " \
|
|
121
|
+
"the runtime counterpart to validate_config -- that one says what will fail, this one says " \
|
|
122
|
+
"what did, on which devices. Reaches rubynative.com and needs `ruby_native login`.",
|
|
123
|
+
inputSchema: {
|
|
124
|
+
type: "object",
|
|
125
|
+
properties: {
|
|
126
|
+
app_id: {
|
|
127
|
+
type: "string",
|
|
128
|
+
description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml."
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
additionalProperties: false
|
|
132
|
+
},
|
|
133
|
+
annotations: { title: "Config errors from devices", readOnlyHint: true, openWorldHint: true }
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "deployed_builds",
|
|
137
|
+
description: "Report the latest iOS and Android build your users can install, and which signals that " \
|
|
138
|
+
"build is too old to understand. check_views compares your templates against the installed " \
|
|
139
|
+
"gem; this compares them against the binary in the store, which is where \"the attribute " \
|
|
140
|
+
"is right but nothing happens on my phone\" usually comes from. Reaches rubynative.com and " \
|
|
141
|
+
"needs `ruby_native login`.",
|
|
142
|
+
inputSchema: {
|
|
143
|
+
type: "object",
|
|
144
|
+
properties: {
|
|
145
|
+
app_id: {
|
|
146
|
+
type: "string",
|
|
147
|
+
description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml."
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
additionalProperties: false
|
|
151
|
+
},
|
|
152
|
+
annotations: { title: "Deployed builds", readOnlyHint: true, openWorldHint: true }
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "build_status",
|
|
156
|
+
description: "Look up one build by id: whether it succeeded, and the error message if it did not. Use " \
|
|
157
|
+
"it to follow a build `ruby_native deploy` started. Reaches rubynative.com and needs " \
|
|
158
|
+
"`ruby_native login`.",
|
|
159
|
+
inputSchema: {
|
|
160
|
+
type: "object",
|
|
161
|
+
properties: {
|
|
162
|
+
build_id: { type: "integer", description: "The build's id, as deploy and this server report it." },
|
|
163
|
+
app_id: {
|
|
164
|
+
type: "string",
|
|
165
|
+
description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml."
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
required: [ "build_id" ],
|
|
169
|
+
additionalProperties: false
|
|
170
|
+
},
|
|
171
|
+
annotations: { title: "Build status", readOnlyHint: true, openWorldHint: true }
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: "link_app",
|
|
175
|
+
description: "Link this project to an app on rubynative.com by writing `ruby_native.app_id` into " \
|
|
176
|
+
"config/ruby_native.yml. Call it when another tool answers that no app is linked. Without " \
|
|
177
|
+
"`app_id` it links the account's only app, or lists the apps to choose from. Reaches " \
|
|
178
|
+
"rubynative.com and needs `ruby_native login`.",
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: "object",
|
|
181
|
+
properties: {
|
|
182
|
+
app_id: { type: "string", description: "The app's public id, from the list this tool returns or the dashboard URL." }
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
annotations: { title: "Link app", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: "status",
|
|
189
|
+
description: "Report where the app is on the way to the App Store and Google Play and what is in the way: " \
|
|
190
|
+
"setup, subscription, the latest build, store state, and a `blocked_by` list per track for its next act. " \
|
|
191
|
+
"Each blocker says who clears it. `who: human` means stop and hand the person the steps: " \
|
|
192
|
+
"paying, creating the app record, or answering a questionnaire Apple gives us no API for. " \
|
|
193
|
+
"Call this first, and again after the person says they have done a step. Before " \
|
|
194
|
+
"`ruby_native login` or a linked app it answers from this checkout instead: the config, the " \
|
|
195
|
+
"first native pass, and what to do next. Otherwise reaches rubynative.com.",
|
|
196
|
+
inputSchema: {
|
|
197
|
+
type: "object",
|
|
198
|
+
properties: {
|
|
199
|
+
app_id: {
|
|
200
|
+
type: "string",
|
|
201
|
+
description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml."
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
additionalProperties: false
|
|
205
|
+
},
|
|
206
|
+
annotations: { title: "App status", readOnlyHint: true, openWorldHint: true }
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: "apple_key_set",
|
|
210
|
+
description: "Hand over the App Store Connect key the person minted: the path to the .p8 they downloaded, " \
|
|
211
|
+
"plus the key ID, the issuer ID, and the Team ID. Only a person can make the key (a Team key with " \
|
|
212
|
+
"the Admin role, under Users and Access > Integrations); ask them for the path, never read the " \
|
|
213
|
+
"file yourself. Ruby Native checks it against Apple and this waits up to half a minute for the " \
|
|
214
|
+
"answer; if it is still checking, read `status` again shortly. Reaches rubynative.com and needs " \
|
|
215
|
+
"`ruby_native login`.",
|
|
216
|
+
inputSchema: {
|
|
217
|
+
type: "object",
|
|
218
|
+
properties: {
|
|
219
|
+
path: { type: "string", description: "Path to the .p8 file, relative to the app root or absolute." },
|
|
220
|
+
key_id: { type: "string", description: "The key ID shown next to the key in App Store Connect." },
|
|
221
|
+
issuer_id: { type: "string", description: "The issuer ID shown above the keys list." },
|
|
222
|
+
team_id: { type: "string", description: "The Team ID from developer.apple.com/account." },
|
|
223
|
+
app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
|
|
224
|
+
},
|
|
225
|
+
required: [ "path", "key_id", "issuer_id", "team_id" ],
|
|
226
|
+
additionalProperties: false
|
|
227
|
+
},
|
|
228
|
+
annotations: { title: "Set the App Store Connect key", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: "permissions_set",
|
|
232
|
+
description: "Set the sentence iOS shows when the app first asks for the camera, the microphone, or the " \
|
|
233
|
+
"photo library. Say what the app does with it, in the user's words. The same three strings decide " \
|
|
234
|
+
"which permissions the Android manifest declares, so setting one turns the feature on for both " \
|
|
235
|
+
"stores. They land in Info.plist and the manifest, so a change ships with the next " \
|
|
236
|
+
"`ruby_native deploy`, not the next Rails deploy. Send only the ones to change; an empty string " \
|
|
237
|
+
"removes one. Reaches rubynative.com and needs `ruby_native login`.",
|
|
238
|
+
inputSchema: {
|
|
239
|
+
type: "object",
|
|
240
|
+
properties: {
|
|
241
|
+
camera: { type: "string", description: "Why the app uses the camera, for example \"To scan tickets at the door.\"" },
|
|
242
|
+
microphone: { type: "string", description: "Why the app uses the microphone." },
|
|
243
|
+
photos: { type: "string", description: "Why the app reads the photo library." },
|
|
244
|
+
app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
|
|
245
|
+
},
|
|
246
|
+
additionalProperties: false
|
|
247
|
+
},
|
|
248
|
+
annotations: { title: "Set permission strings", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: "testers_list",
|
|
252
|
+
description: "List the internal TestFlight testers on the app and whether each has accepted the invite. " \
|
|
253
|
+
"With platform android, Google Play keeps its list in Google Play Console, so the answer is the hand-off " \
|
|
254
|
+
"with the steps. Reaches rubynative.com and needs `ruby_native login`.",
|
|
255
|
+
inputSchema: {
|
|
256
|
+
type: "object",
|
|
257
|
+
properties: {
|
|
258
|
+
platform: { type: "string", enum: [ "ios", "android" ], description: "Defaults to ios." },
|
|
259
|
+
app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
|
|
260
|
+
},
|
|
261
|
+
additionalProperties: false
|
|
262
|
+
},
|
|
263
|
+
annotations: { title: "List testers", readOnlyHint: true, openWorldHint: true }
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: "testers_add",
|
|
267
|
+
description: "Invite an App Store Connect team member to TestFlight for this app. Apple emails them the " \
|
|
268
|
+
"invite; only they can accept it. Someone not on the team is refused with what to do. Reaches " \
|
|
269
|
+
"rubynative.com and needs `ruby_native login`.",
|
|
270
|
+
inputSchema: {
|
|
271
|
+
type: "object",
|
|
272
|
+
properties: {
|
|
273
|
+
email: { type: "string", description: "The Apple ID email the person uses for App Store Connect." },
|
|
274
|
+
platform: { type: "string", enum: [ "ios", "android" ], description: "Defaults to ios." },
|
|
275
|
+
app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
|
|
276
|
+
},
|
|
277
|
+
required: [ "email" ],
|
|
278
|
+
additionalProperties: false
|
|
279
|
+
},
|
|
280
|
+
annotations: { title: "Add a tester", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
name: "icon_set",
|
|
284
|
+
description: "Upload the app icon: a 1024x1024 PNG with no transparency, from a path in this project. It " \
|
|
285
|
+
"ships with the next `ruby_native deploy`. Reaches rubynative.com and needs `ruby_native login`.",
|
|
286
|
+
inputSchema: {
|
|
287
|
+
type: "object",
|
|
288
|
+
properties: {
|
|
289
|
+
path: { type: "string", description: "Path to the PNG, relative to the app root." },
|
|
290
|
+
app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
|
|
291
|
+
},
|
|
292
|
+
required: [ "path" ],
|
|
293
|
+
additionalProperties: false
|
|
294
|
+
},
|
|
295
|
+
annotations: { title: "Set the app icon", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
name: "screenshots_setup",
|
|
299
|
+
description: "Set up store screenshots, which rubynative.com captures against the deployed site: sets " \
|
|
300
|
+
"the 3 to 10 pages to capture, for one store or both. Only pages behind a sign-in need the " \
|
|
301
|
+
"screenshot key: pass sign_in to generate it (returned once, with where to put it in the app's " \
|
|
302
|
+
"Rails credentials and initializer) and deploy the site before capturing. Then call " \
|
|
303
|
+
"screenshots_capture. Reaches rubynative.com and needs `ruby_native login`.",
|
|
304
|
+
inputSchema: {
|
|
305
|
+
type: "object",
|
|
306
|
+
properties: {
|
|
307
|
+
paths: { type: "array", items: { type: "string" }, description: "3 to 10 absolute paths that show the app off, for example [\"/\", \"/inbox\", \"/settings\"]." },
|
|
308
|
+
platform: { type: "string", enum: [ "ios", "android" ], description: "The store the pages are for. Omit to set both." },
|
|
309
|
+
sign_in: { type: "boolean", description: "The pages need a signed-in user: generate the key if there is none." },
|
|
310
|
+
rotate_key: { type: "boolean", description: "Replace an existing key. The old one stops working." },
|
|
311
|
+
app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
|
|
312
|
+
},
|
|
313
|
+
additionalProperties: false
|
|
314
|
+
},
|
|
315
|
+
annotations: { title: "Set up screenshots", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: "screenshots_capture",
|
|
319
|
+
description: "Start a screenshot capture of the deployed site on a phone. Takes about five minutes; follow " \
|
|
320
|
+
"it with screenshots_list. Publishing the result to the store is a click on the dashboard, not " \
|
|
321
|
+
"a tool. Reaches rubynative.com and needs `ruby_native login`.",
|
|
322
|
+
inputSchema: {
|
|
323
|
+
type: "object",
|
|
324
|
+
properties: {
|
|
325
|
+
platform: { type: "string", enum: [ "ios", "android" ], description: "Defaults to ios." },
|
|
326
|
+
app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
|
|
327
|
+
},
|
|
328
|
+
additionalProperties: false
|
|
329
|
+
},
|
|
330
|
+
annotations: { title: "Capture screenshots", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
name: "screenshots_list",
|
|
334
|
+
description: "Report the screenshot setup (key, pages) and the latest capture per platform. Reaches " \
|
|
335
|
+
"rubynative.com and needs `ruby_native login`.",
|
|
336
|
+
inputSchema: {
|
|
337
|
+
type: "object",
|
|
338
|
+
properties: {
|
|
339
|
+
app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
|
|
340
|
+
},
|
|
341
|
+
additionalProperties: false
|
|
342
|
+
},
|
|
343
|
+
annotations: { title: "List screenshots", readOnlyHint: true, openWorldHint: true }
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: "listing_push",
|
|
347
|
+
description: "Save config/ruby_native_listing.yml to Ruby Native as the listing draft for both stores. Write the " \
|
|
348
|
+
"file first: name (30), subtitle (30), keywords (100, comma separated, no spaces), " \
|
|
349
|
+
"whats_new (4000, required on every version after the first), description (4000), privacy_policy_url, support_url, " \
|
|
350
|
+
"primary_category and secondary_category from Apple's list, copyright (\"2026 Trailhead LLC\"), " \
|
|
351
|
+
"support_email and website (Google Play's contact details; Play requires the email), and review_contact, " \
|
|
352
|
+
"who App Review contacts, with first_name, last_name, email, and phone (quoted, a + and the " \
|
|
353
|
+
"country code: \"+1 503 555 0100\"). An ios: or android: block replaces name, subtitle, " \
|
|
354
|
+
"description, or whats_new for that store alone (Apple refuses a listing that mentions Android; Play's " \
|
|
355
|
+
"subtitle, its short description, takes 80). Nothing reaches a store: a person publishes it. " \
|
|
356
|
+
"Reaches rubynative.com and needs `ruby_native login`.",
|
|
357
|
+
inputSchema: {
|
|
358
|
+
type: "object",
|
|
359
|
+
properties: {
|
|
360
|
+
app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
|
|
361
|
+
},
|
|
362
|
+
additionalProperties: false
|
|
363
|
+
},
|
|
364
|
+
annotations: { title: "Push the listing", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
name: "listing_pull",
|
|
368
|
+
description: "Write config/ruby_native_listing.yml from the listing Ruby Native holds, so a draft made on " \
|
|
369
|
+
"the dashboard can be edited in the repo. Reaches rubynative.com and needs `ruby_native login`.",
|
|
370
|
+
inputSchema: {
|
|
371
|
+
type: "object",
|
|
372
|
+
properties: {
|
|
373
|
+
app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
|
|
374
|
+
},
|
|
375
|
+
additionalProperties: false
|
|
376
|
+
},
|
|
377
|
+
annotations: { title: "Pull the listing", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true }
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
name: "demo_account_set",
|
|
381
|
+
description: "Stage the sign-in App Review uses to get past the app's login: an account that exists on " \
|
|
382
|
+
"the production site. It reaches App Store Connect when the listing is published. Keep it " \
|
|
383
|
+
"out of the listing file, which is committed. Reaches rubynative.com and needs `ruby_native login`.",
|
|
384
|
+
inputSchema: {
|
|
385
|
+
type: "object",
|
|
386
|
+
properties: {
|
|
387
|
+
name: { type: "string", description: "The username or email the reviewer signs in with." },
|
|
388
|
+
password: { type: "string", description: "Its password." },
|
|
389
|
+
notes: { type: "string", description: "Anything the reviewer needs to know to reach the app's features." },
|
|
390
|
+
required: { type: "boolean", description: "Whether the app needs a sign-in at all. Defaults to true." },
|
|
391
|
+
app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
|
|
392
|
+
},
|
|
393
|
+
required: [ "name" ],
|
|
394
|
+
additionalProperties: false
|
|
395
|
+
},
|
|
396
|
+
annotations: { title: "Set the demo account", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
name: "submit",
|
|
400
|
+
description: "Submit the app for App Store review, in two calls. Without `confirmation_token` it submits " \
|
|
401
|
+
"NOTHING: it returns the exact listing, build, and checklist that would go to Apple, as a " \
|
|
402
|
+
"summary written to be read aloud, plus a token that expires in ten minutes. Show the person " \
|
|
403
|
+
"that summary and get their explicit yes before calling again with the token. Only then does " \
|
|
404
|
+
"it submit. Until Apple approves, `withdraw` takes it back out of the queue. With platform " \
|
|
405
|
+
"android nothing is ever sent: Ruby Native never promotes to production, so the answer is the " \
|
|
406
|
+
"go-live list with the Google Play Console promotion steps last. Reaches rubynative.com and needs " \
|
|
407
|
+
"`ruby_native login`.",
|
|
408
|
+
inputSchema: {
|
|
409
|
+
type: "object",
|
|
410
|
+
properties: {
|
|
411
|
+
confirmation_token: { type: "string", description: "The token from the previous call, after the person said yes." },
|
|
412
|
+
confirmations: {
|
|
413
|
+
type: "array",
|
|
414
|
+
items: { type: "string", enum: [ "app_privacy", "age_rating", "play_category", "play_content" ] },
|
|
415
|
+
description: "Questionnaires the person says they have completed in App Store Connect or Google Play Console. Send one only after they say so, in their words; never infer it."
|
|
416
|
+
},
|
|
417
|
+
platform: { type: "string", enum: [ "ios", "android" ], description: "Defaults to ios." },
|
|
418
|
+
app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
|
|
419
|
+
},
|
|
420
|
+
additionalProperties: false
|
|
421
|
+
},
|
|
422
|
+
annotations: { title: "Submit for review", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
name: "withdraw",
|
|
426
|
+
description: "Take the app back out of the App Store review queue. Works from submission until Apple " \
|
|
427
|
+
"approves it; after release there is nothing to withdraw. Costs a place in the queue and " \
|
|
428
|
+
"nothing else. Reaches rubynative.com and needs `ruby_native login`.",
|
|
429
|
+
inputSchema: {
|
|
430
|
+
type: "object",
|
|
431
|
+
properties: {
|
|
432
|
+
platform: { type: "string", enum: [ "ios", "android" ], description: "Defaults to ios." },
|
|
433
|
+
app_id: { type: "string", description: "The app's public id. Defaults to ruby_native.app_id in config/ruby_native.yml." }
|
|
434
|
+
},
|
|
435
|
+
additionalProperties: false
|
|
436
|
+
},
|
|
437
|
+
annotations: { title: "Withdraw from review", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }
|
|
438
|
+
}
|
|
439
|
+
].freeze
|
|
440
|
+
|
|
441
|
+
# How many inert signals deployed_builds names before it just counts the
|
|
442
|
+
# rest: enough to act on, not so many that the answer is a wall.
|
|
443
|
+
SIGNAL_PREVIEW = 10
|
|
444
|
+
|
|
445
|
+
# A raw decode failure can run long. The whole value stays in the
|
|
446
|
+
# structured result; this keeps the readable report readable.
|
|
447
|
+
DETAIL_LIMIT = 400
|
|
448
|
+
|
|
449
|
+
# `argv` is accepted so the CLI can build every command the same way; this
|
|
450
|
+
# one takes no flags.
|
|
451
|
+
def initialize(_argv = [], input: $stdin, output: $stdout)
|
|
452
|
+
@input = input
|
|
453
|
+
@output = output
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def run
|
|
457
|
+
@output.sync = true if @output.respond_to?(:sync=)
|
|
458
|
+
Platform.client = "mcp"
|
|
459
|
+
warn "ruby_native #{RubyNative::VERSION} MCP server ready on stdio."
|
|
460
|
+
|
|
461
|
+
while (line = @input.gets)
|
|
462
|
+
next if line.strip.empty?
|
|
463
|
+
|
|
464
|
+
handle(line)
|
|
465
|
+
end
|
|
466
|
+
ensure
|
|
467
|
+
Platform.client = nil
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
private
|
|
471
|
+
|
|
472
|
+
def handle(line)
|
|
473
|
+
message = JSON.parse(line)
|
|
474
|
+
rescue JSON::ParserError => error
|
|
475
|
+
write(nil, error: failure(PARSE_ERROR, "Invalid JSON: #{error.message}"))
|
|
476
|
+
else
|
|
477
|
+
unless message.is_a?(Hash)
|
|
478
|
+
return write(nil, error: failure(INVALID_REQUEST, "A request has to be a JSON object."))
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
id = message["id"]
|
|
482
|
+
# A notification carries no id and takes no reply, not even an error.
|
|
483
|
+
return if id.nil?
|
|
484
|
+
|
|
485
|
+
begin
|
|
486
|
+
write(id, result: result_for(message["method"], message["params"] || {}))
|
|
487
|
+
rescue UnknownMethod => error
|
|
488
|
+
write(id, error: failure(METHOD_NOT_FOUND, error.message))
|
|
489
|
+
rescue ToolFailure => error
|
|
490
|
+
write(id, error: failure(INVALID_PARAMS, error.message))
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def result_for(method, params)
|
|
495
|
+
case method
|
|
496
|
+
when "initialize" then initialize_result(params)
|
|
497
|
+
when "tools/list" then { tools: TOOLS }
|
|
498
|
+
when "tools/call" then call(params)
|
|
499
|
+
when "prompts/list" then { prompts: Prompts::LIST }
|
|
500
|
+
when "prompts/get" then prompt(params)
|
|
501
|
+
when "ping" then {}
|
|
502
|
+
else raise UnknownMethod, "Unknown method: #{method.inspect}."
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def initialize_result(params)
|
|
507
|
+
requested = params["protocolVersion"]
|
|
508
|
+
|
|
509
|
+
{
|
|
510
|
+
protocolVersion: PROTOCOL_VERSIONS.include?(requested) ? requested : PROTOCOL_VERSION,
|
|
511
|
+
capabilities: { tools: {}, prompts: {} },
|
|
512
|
+
serverInfo: { name: "ruby_native", version: RubyNative::VERSION },
|
|
513
|
+
instructions: INSTRUCTIONS
|
|
514
|
+
}
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def prompt(params)
|
|
518
|
+
name = params["name"]
|
|
519
|
+
Prompts.get(name) or raise ToolFailure, "Unknown prompt: #{name.inspect}. Call prompts/list for the ones this server has."
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# What a client hands the model about this server. "Set up Ruby Native in
|
|
523
|
+
# this app" has to be answerable from here plus the docs, so the setup
|
|
524
|
+
# steps and the rules of the road are stated once, in the handshake.
|
|
525
|
+
INSTRUCTIONS = <<~TEXT.strip
|
|
526
|
+
Ruby Native turns this Rails app into an iOS and Android app. The native shell loads the site
|
|
527
|
+
in web views and reads `data-native-*` attributes (signals) from the HTML to render native
|
|
528
|
+
tabs, buttons, forms, and more. `config/ruby_native.yml` configures the shell and is fetched
|
|
529
|
+
by the app on every launch, so config changes ship with a Rails deploy and need no rebuild.
|
|
530
|
+
|
|
531
|
+
Setting up: (1) `config/ruby_native.yml` exists; edit tabs so every path is one the app routes.
|
|
532
|
+
(2) In the layout <head>, add `<%= stylesheet_link_tag :ruby_native %>` and put
|
|
533
|
+
`viewport-fit=cover` in the viewport meta tag. (3) In the layout <body>, add
|
|
534
|
+
`<%= native_tabs_tag %>` and `<%= native_identity_tag(current_user&.id) %>`.
|
|
535
|
+
(4) Run `validate_config` and `check_views`, then `bundle exec ruby_native preview` to see it
|
|
536
|
+
on a phone. Full docs: https://rubynative.com/llms.txt (index) and llms-full.txt (everything).
|
|
537
|
+
|
|
538
|
+
Signals fail silently: an attribute the app does not know is ignored with no error anywhere.
|
|
539
|
+
Call `lookup_signals` before writing one and `check_views` after editing any view.
|
|
540
|
+
|
|
541
|
+
`status` reports where the app is on the way to the store and a `blocked_by` list. A blocker
|
|
542
|
+
whose `who` is `human` is a step only a person can take (pay, create the App Store Connect
|
|
543
|
+
record, answer a questionnaire Apple has no API for). Stop, show them the steps and the URL,
|
|
544
|
+
and read `status` again when they say it is done. Never claim a `verifiable: false` blocker is
|
|
545
|
+
cleared: only the person can, on the dashboard. Before login or a linked app, `status`
|
|
546
|
+
answers from this checkout and says what comes next.
|
|
547
|
+
|
|
548
|
+
The `ship` prompt is the whole way to the stores in one command: install, the first native
|
|
549
|
+
pass, the account, then every store step in order, stopping at the ones only a person can
|
|
550
|
+
take. Start there when asked to ship, set up, or get the app into the stores.
|
|
551
|
+
TEXT
|
|
552
|
+
|
|
553
|
+
def call(params)
|
|
554
|
+
name = params["name"]
|
|
555
|
+
arguments = params["arguments"] || {}
|
|
556
|
+
|
|
557
|
+
# The protocol owns stdout: one JSON message per line and nothing else.
|
|
558
|
+
# Library code underneath is free to `puts` -- Check does, on the paths
|
|
559
|
+
# this does not take -- so stdout is pointed at stderr for the duration
|
|
560
|
+
# of a call rather than trusted to stay quiet. `@output` holds the real
|
|
561
|
+
# stream, so replies are unaffected.
|
|
562
|
+
quietly do
|
|
563
|
+
case name
|
|
564
|
+
when "check_views" then check_views(arguments)
|
|
565
|
+
when "lookup_signals" then lookup_signals(arguments)
|
|
566
|
+
when "validate_config" then validate_config(arguments)
|
|
567
|
+
when "config_errors" then config_errors(arguments)
|
|
568
|
+
when "deployed_builds" then deployed_builds(arguments)
|
|
569
|
+
when "build_status" then build_status(arguments)
|
|
570
|
+
when "status" then status(arguments)
|
|
571
|
+
when "link_app"
|
|
572
|
+
outcome = Link.link(arguments["app_id"].to_s.strip.empty? ? nil : arguments["app_id"].to_s.strip)
|
|
573
|
+
tool_result(outcome.text, outcome.data, is_error: !outcome.ok)
|
|
574
|
+
when "apple_key_set"
|
|
575
|
+
with_app(arguments) do |app_id|
|
|
576
|
+
Apple.set_key(app_id, path: string!(arguments, "path"), key_id: string!(arguments, "key_id"),
|
|
577
|
+
issuer_id: string!(arguments, "issuer_id"), team_id: string!(arguments, "team_id"), wait: 30)
|
|
578
|
+
end
|
|
579
|
+
when "permissions_set" then with_app(arguments) { |app_id| Permissions.set(app_id, arguments.slice("camera", "microphone", "photos")) }
|
|
580
|
+
when "testers_list" then with_app(arguments) { |app_id| Testers.list(app_id, platform: arguments["platform"] || "ios") }
|
|
581
|
+
when "testers_add" then with_app(arguments) { |app_id| Testers.add(app_id, string!(arguments, "email"), platform: arguments["platform"] || "ios") }
|
|
582
|
+
when "icon_set" then with_app(arguments) { |app_id| Icon.set(app_id, string!(arguments, "path")) }
|
|
583
|
+
when "screenshots_setup" then with_app(arguments) { |app_id| Screenshots.setup(app_id, paths: string_list(arguments["paths"], "paths") || [], platforms: (arguments["platform"] ? [ arguments["platform"] ] : nil), sign_in: arguments["sign_in"] == true, rotate_key: arguments["rotate_key"] == true) }
|
|
584
|
+
when "screenshots_capture" then with_app(arguments) { |app_id| Screenshots.capture(app_id, platform: arguments["platform"] || "ios") }
|
|
585
|
+
when "screenshots_list" then with_app(arguments) { |app_id| Screenshots.list(app_id) }
|
|
586
|
+
when "submit"
|
|
587
|
+
with_app(arguments) do |app_id|
|
|
588
|
+
token = arguments["confirmation_token"]
|
|
589
|
+
platform = arguments["platform"] || "ios"
|
|
590
|
+
confirmations = string_list(arguments["confirmations"], "confirmations") || []
|
|
591
|
+
if platform == "android" || token.to_s.strip.empty?
|
|
592
|
+
Submit.preview(app_id, platform: platform, confirmations: confirmations)
|
|
593
|
+
else
|
|
594
|
+
Submit.submit(app_id, token.to_s.strip)
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
when "withdraw" then with_app(arguments) { |app_id| Submit.withdraw(app_id, platform: arguments["platform"] || "ios") }
|
|
598
|
+
when "listing_push" then with_app(arguments) { |app_id| Listing.push(app_id) }
|
|
599
|
+
when "listing_pull" then with_app(arguments) { |app_id| Listing.pull(app_id) }
|
|
600
|
+
when "demo_account_set"
|
|
601
|
+
with_app(arguments) do |app_id|
|
|
602
|
+
DemoAccount.set(app_id, name: string!(arguments, "name"), password: arguments["password"], notes: arguments["notes"],
|
|
603
|
+
required: arguments.fetch("required", true) == true)
|
|
604
|
+
end
|
|
605
|
+
else raise ToolFailure, "Unknown tool: #{name.inspect}. Call tools/list for the ones this server has."
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
rescue ToolFailure
|
|
609
|
+
raise
|
|
610
|
+
rescue StandardError => error
|
|
611
|
+
# A tool that blew up is an answer the model can work with, so it comes
|
|
612
|
+
# back as a result rather than a protocol error it cannot see.
|
|
613
|
+
tool_result("#{error.class}: #{error.message}", { error: error.message }, is_error: true)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# --- Tools ---
|
|
617
|
+
|
|
618
|
+
def check_views(arguments)
|
|
619
|
+
paths = string_list(arguments["paths"], "paths") || Check::DEFAULT_PATHS
|
|
620
|
+
|
|
621
|
+
unless Check.herb_available?
|
|
622
|
+
return tool_result(<<~TEXT.strip, { herb_available: false }, is_error: true)
|
|
623
|
+
check_views needs the herb gem, which parses HTML and ERB together.
|
|
624
|
+
|
|
625
|
+
Add it to the Gemfile:
|
|
626
|
+
gem "herb"
|
|
627
|
+
|
|
628
|
+
Rails 8.2 and later already ship it as a dependency of Action View.
|
|
629
|
+
TEXT
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
files = Check.template_files(paths: paths)
|
|
633
|
+
offenses = Check.signal_offenses(paths: paths) || []
|
|
634
|
+
|
|
635
|
+
if files.empty?
|
|
636
|
+
return tool_result("No .html.erb templates found in #{paths.join(", ")}.",
|
|
637
|
+
{ paths: paths, checked: 0, offenses: [] })
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
errors, warnings = offenses.partition { |offense| offense.severity == :error }
|
|
641
|
+
|
|
642
|
+
tool_result(Check.report_lines(files, offenses).join("\n"), {
|
|
643
|
+
paths: paths,
|
|
644
|
+
checked: files.size,
|
|
645
|
+
gem_version: RubyNative::VERSION,
|
|
646
|
+
errors: errors.size,
|
|
647
|
+
warnings: warnings.size,
|
|
648
|
+
offenses: offenses.map do |offense|
|
|
649
|
+
{ file: offense.file, line: offense.line, severity: offense.severity.to_s, message: offense.message }
|
|
650
|
+
end
|
|
651
|
+
})
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def lookup_signals(arguments)
|
|
655
|
+
name = arguments["name"]
|
|
656
|
+
return signal_list if name.nil? || name.to_s.strip.empty?
|
|
657
|
+
|
|
658
|
+
raise ToolFailure, "`name` has to be a string." unless name.is_a?(String)
|
|
659
|
+
|
|
660
|
+
signal_detail(qualify(name.strip))
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def validate_config(arguments)
|
|
664
|
+
path = arguments["path"] || ConfigValidator::DEFAULT_PATH
|
|
665
|
+
raise ToolFailure, "`path` has to be a string." unless path.is_a?(String)
|
|
666
|
+
|
|
667
|
+
offenses = in_line_order(ConfigValidator.run(path: path))
|
|
668
|
+
errors, warnings = offenses.partition { |offense| offense.severity == :error }
|
|
669
|
+
|
|
670
|
+
tool_result(config_report(path, offenses), {
|
|
671
|
+
path: path,
|
|
672
|
+
errors: errors.size,
|
|
673
|
+
warnings: warnings.size,
|
|
674
|
+
offenses: offenses.map do |offense|
|
|
675
|
+
{ file: path, key: offense.key, line: offense.line, severity: offense.severity.to_s, message: offense.message }
|
|
676
|
+
end
|
|
677
|
+
})
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
# --- Tools that read the API ---
|
|
681
|
+
|
|
682
|
+
def config_errors(arguments)
|
|
683
|
+
app_id = resolve_app_id(arguments)
|
|
684
|
+
return tool_result(Platform::NO_APP_ID, { app_id: nil }, is_error: true) unless app_id
|
|
685
|
+
|
|
686
|
+
result = Platform.get("/api/v1/apps/#{app_id}/config_error_reports")
|
|
687
|
+
unless result.ok?
|
|
688
|
+
return tool_result(failure_for(result, app_id), { app_id: app_id }, is_error: true)
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
reports = Array(result.value)
|
|
692
|
+
|
|
693
|
+
tool_result(config_errors_report(reports), { app_id: app_id, count: reports.size, reports: reports })
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
def deployed_builds(arguments)
|
|
697
|
+
app_id = resolve_app_id(arguments)
|
|
698
|
+
return tool_result(Platform::NO_APP_ID, { app_id: nil }, is_error: true) unless app_id
|
|
699
|
+
|
|
700
|
+
result = Platform.get("/api/v1/apps/#{app_id}/builds/latest?platform=all")
|
|
701
|
+
return tool_result(failure_for(result, app_id), { app_id: app_id }, is_error: true) unless result.ok?
|
|
702
|
+
|
|
703
|
+
builds = result.value.is_a?(Hash) ? result.value : {}
|
|
704
|
+
|
|
705
|
+
tool_result(deployed_builds_report(builds), {
|
|
706
|
+
app_id: app_id,
|
|
707
|
+
gem_version: RubyNative::VERSION,
|
|
708
|
+
platforms: builds.transform_values do |state|
|
|
709
|
+
state.is_a?(Hash) ? state.merge("inert_signals" => inert_signals(state["gem_version"])) : state
|
|
710
|
+
end
|
|
711
|
+
})
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
def build_status(arguments)
|
|
715
|
+
build_id = arguments["build_id"]
|
|
716
|
+
unless build_id.is_a?(Integer) || build_id.to_s.match?(/\A\d+\z/)
|
|
717
|
+
raise ToolFailure, "`build_id` is required and has to be a number."
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
app_id = resolve_app_id(arguments)
|
|
721
|
+
return tool_result(Platform::NO_APP_ID, { app_id: nil }, is_error: true) unless app_id
|
|
722
|
+
|
|
723
|
+
result = Platform.get("/api/v1/apps/#{app_id}/builds/#{build_id}")
|
|
724
|
+
unless result.ok?
|
|
725
|
+
# Either half of the path can be the missing one, and the API does
|
|
726
|
+
# not say which.
|
|
727
|
+
message = result.not_found? ? "No build #{build_id} in app #{app_id.inspect} on this account." : result.error
|
|
728
|
+
return tool_result(message, { app_id: app_id, build_id: build_id }, is_error: true)
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
build = result.value.is_a?(Hash) ? result.value : {}
|
|
732
|
+
|
|
733
|
+
tool_result(build_status_report(build), build.merge("app_id" => app_id))
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# Before there is anything to fetch, the answer is what this checkout
|
|
737
|
+
# shows: the same report `ruby_native status` prints, so the first thing
|
|
738
|
+
# an agent asks has an answer even on a fresh clone with no account.
|
|
739
|
+
def status(arguments)
|
|
740
|
+
app_id = resolve_app_id(arguments)
|
|
741
|
+
if app_id.nil? || Credentials.token.nil?
|
|
742
|
+
local = Status.local
|
|
743
|
+
return tool_result(Status.local_report(local), local)
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
result = Status.fetch(app_id)
|
|
747
|
+
unless result.ok?
|
|
748
|
+
return tool_result(Status.failure_message(result, app_id), { app_id: app_id }, is_error: true)
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
status = result.value.is_a?(Hash) ? result.value : {}
|
|
752
|
+
|
|
753
|
+
tool_result(Status.report(status), status)
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# The CLI commands do the work; a tool is the same verb answered in the
|
|
757
|
+
# protocol's shape.
|
|
758
|
+
def with_app(arguments)
|
|
759
|
+
app_id = resolve_app_id(arguments)
|
|
760
|
+
return tool_result(Platform::NO_APP_ID, { app_id: nil }, is_error: true) unless app_id
|
|
761
|
+
|
|
762
|
+
outcome = yield(app_id)
|
|
763
|
+
tool_result(outcome.text, outcome.data, is_error: !outcome.ok)
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def string!(arguments, name)
|
|
767
|
+
value = arguments[name]
|
|
768
|
+
raise ToolFailure, "`#{name}` is required and has to be a string." unless value.is_a?(String) && !value.strip.empty?
|
|
769
|
+
|
|
770
|
+
value.strip
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
# --- Rendering ---
|
|
774
|
+
|
|
775
|
+
def config_errors_report(reports)
|
|
776
|
+
if reports.empty?
|
|
777
|
+
return "No config errors reported in the last 48 hours. Only a device running a build reports here, so " \
|
|
778
|
+
"this says nothing about config you have not deployed yet."
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
lines = [ "#{reports.size} config #{reports.size == 1 ? "error" : "errors"} reported by devices in the " \
|
|
782
|
+
"last 48 hours, newest first.", "" ]
|
|
783
|
+
|
|
784
|
+
reports.each do |report|
|
|
785
|
+
lines << "#{report["error_type"]} — #{report["headline"]}"
|
|
786
|
+
lines << " #{report["error_description"]}" unless report["error_description"].to_s.strip.empty?
|
|
787
|
+
lines << " #{devices_line(report)}"
|
|
788
|
+
lines << " first seen #{report["first_seen_at"]}, last seen #{report["last_seen_at"]}"
|
|
789
|
+
|
|
790
|
+
detail = report["error_detail"].to_s.strip
|
|
791
|
+
lines << " #{truncate(detail, DETAIL_LIMIT).gsub("\n", "\n ")}" unless detail.empty?
|
|
792
|
+
lines << ""
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
lines.join("\n").strip
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def devices_line(report)
|
|
799
|
+
os = [ report["os_name"], report["os_version"] ].map(&:to_s).reject(&:empty?).join(" ")
|
|
800
|
+
version = report["app_version"].to_s
|
|
801
|
+
build = report["build_number"].to_s
|
|
802
|
+
app = build.empty? ? version : "#{version} (#{build})".strip
|
|
803
|
+
|
|
804
|
+
[ os, report["device_model"].to_s, app.empty? ? "" : "app #{app}" ].reject(&:empty?).join(" · ")
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
def deployed_builds_report(builds)
|
|
808
|
+
return "#{Platform::HOST} has no build information for this app." if builds.empty?
|
|
809
|
+
|
|
810
|
+
lines = [ "This project is on ruby_native #{RubyNative::VERSION}.", "" ]
|
|
811
|
+
|
|
812
|
+
builds.each do |platform, state|
|
|
813
|
+
label = platform == "ios" ? "iOS" : platform.to_s.capitalize
|
|
814
|
+
|
|
815
|
+
unless state.is_a?(Hash)
|
|
816
|
+
lines << "#{label} — no information."
|
|
817
|
+
next
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
unless state["deployable"]
|
|
821
|
+
lines << "#{label} — not set up for deploys yet."
|
|
822
|
+
next
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
built = state["gem_version"]
|
|
826
|
+
if built.to_s.empty?
|
|
827
|
+
lines << "#{label} — set up to deploy, but nothing has been built yet."
|
|
828
|
+
next
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
lines << "#{label} — build #{state["number"]} (#{state["version"]}), #{state["status"]}, " \
|
|
832
|
+
"made with ruby_native #{built}."
|
|
833
|
+
lines.concat(skew_lines(built))
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
lines.join("\n").strip
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# The point of the whole tool: a signal the shipped binary predates is
|
|
840
|
+
# inert on the phone in someone's hand, and says nothing about it there
|
|
841
|
+
# or anywhere else.
|
|
842
|
+
def skew_lines(built)
|
|
843
|
+
inert = inert_signals(built)
|
|
844
|
+
return [ " Nothing in the signal vocabulary is newer than that build." ] if inert.empty?
|
|
845
|
+
|
|
846
|
+
shown = inert.first(SIGNAL_PREVIEW)
|
|
847
|
+
listed = shown.join(", ")
|
|
848
|
+
remaining = inert.size - shown.size
|
|
849
|
+
listed += ", and #{remaining} more" if remaining.positive?
|
|
850
|
+
|
|
851
|
+
[
|
|
852
|
+
" #{inert.size} #{inert.size == 1 ? "signal is" : "signals are"} newer than that build, so they do " \
|
|
853
|
+
"nothing on the app your users have, with no error anywhere: #{listed}.",
|
|
854
|
+
" Deploy on #{RubyNative::VERSION} to use them."
|
|
855
|
+
]
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
def inert_signals(built)
|
|
859
|
+
return [] if built.to_s.empty?
|
|
860
|
+
|
|
861
|
+
deployed = Gem::Version.new(built.to_s.delete_prefix("v"))
|
|
862
|
+
|
|
863
|
+
Signals.names.select do |name|
|
|
864
|
+
since = Signals.since(name)
|
|
865
|
+
since && Gem::Version.new(since) > deployed
|
|
866
|
+
end
|
|
867
|
+
rescue ArgumentError
|
|
868
|
+
# gem_version reaches the API as unvalidated CLI input, so it may not
|
|
869
|
+
# be a version at all. No comparison beats a wrong one.
|
|
870
|
+
[]
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
def build_status_report(build)
|
|
874
|
+
return "No build information came back." if build.empty?
|
|
875
|
+
|
|
876
|
+
label = build["platform"] == "ios" ? "iOS" : build["platform"].to_s.capitalize
|
|
877
|
+
lines = [ "#{label} build #{build["number"]} (#{build["version"]}) — #{build["status"]}." ]
|
|
878
|
+
|
|
879
|
+
if build["gem_version"].to_s != ""
|
|
880
|
+
lines << "Made with ruby_native #{build["gem_version"]} on Ruby Native #{build["native_version"]}."
|
|
881
|
+
end
|
|
882
|
+
lines << "Error: #{build["error_message"]}" unless build["error_message"].to_s.strip.empty?
|
|
883
|
+
lines << build["notice"] unless build["notice"].to_s.strip.empty?
|
|
884
|
+
|
|
885
|
+
lines.join("\n")
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def signal_list
|
|
890
|
+
rows = Signals.names.map do |name|
|
|
891
|
+
{ name: name, since: Signals.since(name), helper: Signals.helper(name), singleton: Signals.singleton?(name) }
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
width = Signals.names.map(&:length).max
|
|
895
|
+
lines = rows.map do |row|
|
|
896
|
+
"#{row[:name].ljust(width)} #{(row[:since] || "-").ljust(8)}#{row[:helper]}".rstrip
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
text = <<~TEXT.strip
|
|
900
|
+
#{Signals.names.size} signals, as of ruby_native #{RubyNative::VERSION}. Columns: signal, the gem version
|
|
901
|
+
that introduced it, the helper that emits it. A signal needing a version newer than the app's build is
|
|
902
|
+
ignored on device without an error. Signals with no version are written by the app, not by your views.
|
|
903
|
+
|
|
904
|
+
#{lines.join("\n")}
|
|
905
|
+
|
|
906
|
+
What each one does: https://rubynative.com/docs
|
|
907
|
+
TEXT
|
|
908
|
+
|
|
909
|
+
tool_result(text, { count: rows.size, gem_version: RubyNative::VERSION, signals: rows })
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
def signal_detail(name)
|
|
913
|
+
unless Signals.known?(name)
|
|
914
|
+
suggestion = Signals.nearest(name)
|
|
915
|
+
text = "`#{name}` is not a Ruby Native signal, so the apps ignore it."
|
|
916
|
+
text += " Did you mean `#{suggestion}`?" if suggestion
|
|
917
|
+
|
|
918
|
+
return tool_result(text, { name: name, known: false, suggestion: suggestion })
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
since = Signals.since(name)
|
|
922
|
+
supported = since.nil? || Gem::Version.new(RubyNative::VERSION) >= Gem::Version.new(since)
|
|
923
|
+
|
|
924
|
+
details = []
|
|
925
|
+
details << (since ? "Needs ruby_native #{since}." : "Written by the app, not by your views.")
|
|
926
|
+
details << "Emitted by `#{Signals.helper(name)}`." if Signals.helper(name)
|
|
927
|
+
details << "Read once per page: a second element carrying it is ignored." if Signals.singleton?(name)
|
|
928
|
+
details << "This app is on #{RubyNative::VERSION}, so it is inert here until the gem is updated." unless supported
|
|
929
|
+
|
|
930
|
+
tool_result("`#{name}` — #{details.join(" ")}", {
|
|
931
|
+
name: name,
|
|
932
|
+
known: true,
|
|
933
|
+
since: since,
|
|
934
|
+
helper: Signals.helper(name),
|
|
935
|
+
singleton: Signals.singleton?(name),
|
|
936
|
+
gem_version: RubyNative::VERSION,
|
|
937
|
+
supported: supported
|
|
938
|
+
})
|
|
939
|
+
end
|
|
940
|
+
|
|
941
|
+
# Reading order, so a reader works down the file. Offenses the validator
|
|
942
|
+
# could not pin to a line go last rather than to the top.
|
|
943
|
+
def in_line_order(offenses)
|
|
944
|
+
offenses.each_with_index.sort_by { |offense, index| [offense.line || Float::INFINITY, index] }.map(&:first)
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
def config_report(path, offenses)
|
|
948
|
+
return "Checked #{path}. No problems found." if offenses.empty?
|
|
949
|
+
|
|
950
|
+
errors, warnings = offenses.partition { |offense| offense.severity == :error }
|
|
951
|
+
|
|
952
|
+
lines = [path]
|
|
953
|
+
offenses.each do |offense|
|
|
954
|
+
location = "#{offense.line}:".ljust(5)
|
|
955
|
+
location = " " * 5 unless offense.line
|
|
956
|
+
lines << " #{location}#{offense.severity.to_s.ljust(8)}#{offense.message}"
|
|
957
|
+
end
|
|
958
|
+
lines << ""
|
|
959
|
+
lines << "#{errors.size} #{errors.size == 1 ? "error" : "errors"}, " \
|
|
960
|
+
"#{warnings.size} #{warnings.size == 1 ? "warning" : "warnings"}."
|
|
961
|
+
|
|
962
|
+
lines.join("\n")
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
# --- Protocol ---
|
|
966
|
+
|
|
967
|
+
def tool_result(text, structured = nil, is_error: false)
|
|
968
|
+
result = { content: [{ type: "text", text: text }], isError: is_error }
|
|
969
|
+
result[:structuredContent] = structured if structured
|
|
970
|
+
result
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
def failure(code, message)
|
|
974
|
+
{ code: code, message: message }
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
def write(id, body)
|
|
978
|
+
@output.puts(JSON.generate({ jsonrpc: "2.0", id: id }.merge(body)))
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
def quietly
|
|
982
|
+
original = $stdout
|
|
983
|
+
$stdout = $stderr
|
|
984
|
+
yield
|
|
985
|
+
ensure
|
|
986
|
+
$stdout = original
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
# Every app-scoped route answers 404 the same way for an app the token
|
|
990
|
+
# cannot see, which is the only 404 these tools can provoke.
|
|
991
|
+
def failure_for(result, app_id)
|
|
992
|
+
return result.error unless result.not_found?
|
|
993
|
+
|
|
994
|
+
"No app #{app_id.inspect} on this account. Check `ruby_native.app_id` in #{Platform::CONFIG_PATH}, " \
|
|
995
|
+
"or pass `app_id`."
|
|
996
|
+
end
|
|
997
|
+
|
|
998
|
+
def resolve_app_id(arguments)
|
|
999
|
+
app_id = arguments["app_id"]
|
|
1000
|
+
raise ToolFailure, "`app_id` has to be a string." unless app_id.nil? || app_id.is_a?(String)
|
|
1001
|
+
|
|
1002
|
+
app_id = Platform.app_id if app_id.to_s.strip.empty?
|
|
1003
|
+
app_id.to_s.strip.empty? ? nil : app_id
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
def truncate(text, limit)
|
|
1007
|
+
text.length > limit ? "#{text[0, limit].rstrip}…" : text
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
def string_list(value, name)
|
|
1011
|
+
return nil if value.nil?
|
|
1012
|
+
raise ToolFailure, "`#{name}` has to be a list of strings." unless value.is_a?(Array) && value.all?(String)
|
|
1013
|
+
|
|
1014
|
+
entries = value.map(&:strip).reject(&:empty?)
|
|
1015
|
+
entries.empty? ? nil : entries
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
# Agents reach for the bare name as often as the attribute, and both mean
|
|
1019
|
+
# the same thing here. A name that is already prefixed is left alone, so a
|
|
1020
|
+
# typo still gets its "did you mean".
|
|
1021
|
+
def qualify(name)
|
|
1022
|
+
name.start_with?("data-native-") ? name : "data-native-#{name.delete_prefix("data-")}"
|
|
1023
|
+
end
|
|
1024
|
+
end
|
|
1025
|
+
end
|
|
1026
|
+
end
|