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,172 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "ruby_native/cli/credentials"
|
|
6
|
+
require "ruby_native/cli/mcp/config_validator"
|
|
7
|
+
|
|
8
|
+
module RubyNative
|
|
9
|
+
class CLI
|
|
10
|
+
class Mcp
|
|
11
|
+
# The slice of the Ruby Native API the CLI and the MCP server share,
|
|
12
|
+
# using the token `ruby_native login` already stored.
|
|
13
|
+
#
|
|
14
|
+
# Nothing here raises. Every failure comes back as a Result the callers
|
|
15
|
+
# turn into something a person or a model can act on -- "run ruby_native
|
|
16
|
+
# login", "this app is blocked by these things" -- because an agent that
|
|
17
|
+
# gets an exception learns nothing it can fix, and a server that cannot
|
|
18
|
+
# be reached is an answer rather than a crash.
|
|
19
|
+
class Platform
|
|
20
|
+
HOST = ENV.fetch("RUBY_NATIVE_HOST", "https://rubynative.com")
|
|
21
|
+
CONFIG_PATH = "config/ruby_native.yml".freeze
|
|
22
|
+
TIMEOUT = 10
|
|
23
|
+
# Icons and captures take longer than a status read.
|
|
24
|
+
WRITE_TIMEOUT = 60
|
|
25
|
+
|
|
26
|
+
# Sent only while the MCP server runs, so the dashboard marks an agent as connected from that, never from
|
|
27
|
+
# the CLI or CI, which call through this same client.
|
|
28
|
+
CLIENT_HEADER = "X-Ruby-Native-Client".freeze
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
attr_accessor :client
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
NOT_LOGGED_IN = "Not logged in to Ruby Native. Run `ruby_native login` on this machine, " \
|
|
35
|
+
"or set RUBY_NATIVE_TOKEN.".freeze
|
|
36
|
+
NO_APP_ID = "No app is linked to this project. Run `ruby_native deploy` once to link it, which writes " \
|
|
37
|
+
"`ruby_native.app_id` into #{CONFIG_PATH}, or pass `app_id` to this tool.".freeze
|
|
38
|
+
|
|
39
|
+
# `body` is the parsed error response, when there was one: the API
|
|
40
|
+
# explains a refusal with `blocked_by` (the blockers contract) or
|
|
41
|
+
# `errors` (a validation failure), and both are worth more than the
|
|
42
|
+
# status code.
|
|
43
|
+
Result = Struct.new(:value, :error, :status, :body, keyword_init: true) do
|
|
44
|
+
def ok?
|
|
45
|
+
error.nil?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def not_found?
|
|
49
|
+
status == 404
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def blocked_by
|
|
53
|
+
list = body.is_a?(Hash) ? body["blocked_by"] : nil
|
|
54
|
+
list.is_a?(Array) ? list : []
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def field_errors
|
|
58
|
+
errors = body.is_a?(Hash) ? body["errors"] : nil
|
|
59
|
+
errors.is_a?(Hash) ? errors : {}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.get(path)
|
|
64
|
+
request(Net::HTTP::Get, path)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.patch(path, json)
|
|
68
|
+
request(Net::HTTP::Patch, path, json: json, timeout: WRITE_TIMEOUT)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.post(path, json = nil)
|
|
72
|
+
request(Net::HTTP::Post, path, json: json, timeout: WRITE_TIMEOUT)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.put_file(path, field:, file:, content_type:)
|
|
76
|
+
request(Net::HTTP::Put, path, timeout: WRITE_TIMEOUT) do |req|
|
|
77
|
+
req.set_form([ [ field, File.open(file, "rb"), { filename: File.basename(file), content_type: content_type } ] ], "multipart/form-data")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.request(klass, path, json: nil, timeout: TIMEOUT)
|
|
82
|
+
token = Credentials.token
|
|
83
|
+
return Result.new(error: NOT_LOGGED_IN) unless token
|
|
84
|
+
|
|
85
|
+
uri = URI("#{HOST}#{path}")
|
|
86
|
+
req = klass.new(uri)
|
|
87
|
+
req["Authorization"] = "Token #{token}"
|
|
88
|
+
req[CLIENT_HEADER] = client if client
|
|
89
|
+
if json
|
|
90
|
+
req["Content-Type"] = "application/json"
|
|
91
|
+
req.body = JSON.generate(json)
|
|
92
|
+
end
|
|
93
|
+
yield req if block_given?
|
|
94
|
+
|
|
95
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https",
|
|
96
|
+
open_timeout: TIMEOUT, read_timeout: timeout) do |http|
|
|
97
|
+
http.request(req)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
interpret(response)
|
|
101
|
+
rescue StandardError => error
|
|
102
|
+
# Timeouts, DNS, TLS, a proxy in the way: all the same answer.
|
|
103
|
+
Result.new(error: "Could not reach #{HOST}: #{error.class}: #{error.message}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# app_id lives in the file the validator reads, and that file can carry
|
|
107
|
+
# ERB, so tags are stubbed out the same way before parsing. A file that
|
|
108
|
+
# will not parse means no app_id, which reads as "link this app".
|
|
109
|
+
# Adds `ruby_native: app_id:` to the config, or fills it in under an
|
|
110
|
+
# existing `ruby_native:` key, and leaves everything else as written.
|
|
111
|
+
def self.write_app_id(app_id, path = CONFIG_PATH)
|
|
112
|
+
raw = File.exist?(path) ? File.read(path) : ""
|
|
113
|
+
raw = if raw.match?(/^ruby_native:\s*\n(\s+app_id:.*\n)?/)
|
|
114
|
+
raw.sub(/^(ruby_native:\s*\n)(\s+app_id:.*\n)?/, "\\1 app_id: #{app_id}\n")
|
|
115
|
+
else
|
|
116
|
+
raw.rstrip + "\n\nruby_native:\n app_id: #{app_id}\n"
|
|
117
|
+
end
|
|
118
|
+
File.write(path, raw)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.app_id(path = CONFIG_PATH)
|
|
122
|
+
return nil unless File.exist?(path)
|
|
123
|
+
|
|
124
|
+
source = File.read(path, encoding: "UTF-8")
|
|
125
|
+
.gsub(ConfigValidator::ERB_TAG, ConfigValidator::ERB_SENTINEL)
|
|
126
|
+
config = YAML.load(source, aliases: true)
|
|
127
|
+
return nil unless config.is_a?(Hash)
|
|
128
|
+
|
|
129
|
+
value = config.dig("ruby_native", "app_id")
|
|
130
|
+
value&.to_s
|
|
131
|
+
rescue Psych::SyntaxError, SystemCallError, ArgumentError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.interpret(response)
|
|
136
|
+
case response
|
|
137
|
+
when Net::HTTPSuccess
|
|
138
|
+
body = body_of(response)
|
|
139
|
+
Result.new(value: body.empty? ? nil : JSON.parse(body), status: response.code.to_i)
|
|
140
|
+
when Net::HTTPUnauthorized
|
|
141
|
+
Result.new(error: "#{HOST} rejected the stored token. Run `ruby_native login` again.", status: 401)
|
|
142
|
+
when Net::HTTPNotFound
|
|
143
|
+
Result.new(error: "#{HOST} returned 404.", status: 404)
|
|
144
|
+
else
|
|
145
|
+
parsed = parse(body_of(response))
|
|
146
|
+
message = parsed.is_a?(Hash) && parsed["error"] ? parsed["error"] : "#{HOST} returned #{response.code}."
|
|
147
|
+
Result.new(error: message, status: response.code.to_i, body: parsed)
|
|
148
|
+
end
|
|
149
|
+
rescue JSON::ParserError
|
|
150
|
+
Result.new(error: "#{HOST} returned a response that is not JSON.", status: response.code.to_i)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.parse(body)
|
|
154
|
+
JSON.parse(body)
|
|
155
|
+
rescue JSON::ParserError, TypeError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# A body that was never read raises instead of coming back empty, and a
|
|
160
|
+
# request that failed part way is exactly when that happens. Reading
|
|
161
|
+
# the status is still worth something, so this never lets the body
|
|
162
|
+
# decide whether there is an answer at all.
|
|
163
|
+
def self.body_of(response)
|
|
164
|
+
response.body.to_s
|
|
165
|
+
rescue IOError
|
|
166
|
+
""
|
|
167
|
+
end
|
|
168
|
+
private_class_method :interpret, :parse, :body_of
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
require "ruby_native/cli/mcp/platform"
|
|
2
|
+
|
|
3
|
+
module RubyNative
|
|
4
|
+
class CLI
|
|
5
|
+
class Mcp
|
|
6
|
+
# The one prompt this server publishes. A client with prompt support
|
|
7
|
+
# shows it as a slash command (Claude Code: /mcp__ruby-native__ship);
|
|
8
|
+
# anything else gets the same text from `ruby_native ship`.
|
|
9
|
+
#
|
|
10
|
+
# It is the whole journey in one command because `status` is the map:
|
|
11
|
+
# the dashboard's rail and this prompt read the same next act and the
|
|
12
|
+
# same blockers, so an agent and a person are told the same thing.
|
|
13
|
+
module Prompts
|
|
14
|
+
SHIP = <<~TEXT.strip
|
|
15
|
+
Ship this Rails app to the App Store and Google Play with Ruby Native. Work through the
|
|
16
|
+
steps below in order, one at a time. Stop whenever a step is the person's to take, tell
|
|
17
|
+
them exactly what to do, and wait. After every step, say what you did and what is next.
|
|
18
|
+
|
|
19
|
+
`status` is the map. It says where the app is and what is in the way, and it answers
|
|
20
|
+
before there is an account. Read it first and again after every step, yours or the
|
|
21
|
+
person's. Never say a step is done that `status` does not show as done.
|
|
22
|
+
|
|
23
|
+
## 1. Ruby Native is in the app
|
|
24
|
+
|
|
25
|
+
If config/ruby_native.yml is missing: run `bundle add ruby_native`, then
|
|
26
|
+
`bin/rails generate ruby_native:install`. The generator reads config/routes.rb and writes
|
|
27
|
+
the config with real tab paths.
|
|
28
|
+
|
|
29
|
+
## 2. The first native pass
|
|
30
|
+
|
|
31
|
+
If the layout does not yet have `stylesheet_link_tag :ruby_native`, `native_tabs_tag` and
|
|
32
|
+
`native_identity_tag`, make these seven changes in this order, and stop after the
|
|
33
|
+
seventh. Read https://rubynative.com/llms-full.txt first: it is the full documentation as
|
|
34
|
+
plain text, with the exact helper signatures. The config is config/ruby_native.yml.
|
|
35
|
+
|
|
36
|
+
1. Decide the mode before you change anything, because it changes step 3. Advanced Mode
|
|
37
|
+
gives native push and pop transitions and a native navigation bar, and it needs Turbo
|
|
38
|
+
Drive on every single page. Search the app for `data-turbo="false"`, for
|
|
39
|
+
`Turbo.session.drive = false`, and for any layout or page that does not load Turbo at
|
|
40
|
+
all. If you find none of those, set `app.mode: advanced` in config/ruby_native.yml. If
|
|
41
|
+
you find any, leave the mode alone and tell the person exactly where they are: a page
|
|
42
|
+
without Turbo renders a configuration error instead of the page.
|
|
43
|
+
2. Hide the web header, footer and any web navigation from native users by adding the
|
|
44
|
+
`native-hidden` class to them. Prefer that over `unless native_app?`: it is one
|
|
45
|
+
template and one cached fragment for both web and native, so nothing can serve the
|
|
46
|
+
wrong variant. Reach for `native_app?` only when rendering the thing at all is
|
|
47
|
+
expensive or has a side effect.
|
|
48
|
+
3. Opt into safe areas so content stops sitting under the status bar and the home
|
|
49
|
+
indicator: `viewport-fit=cover` on the viewport meta tag and
|
|
50
|
+
`stylesheet_link_tag :ruby_native` in the head. Then put `native-inset` on the content
|
|
51
|
+
wrapper, or `native-inset-top` instead if you turned on Advanced Mode in step 1,
|
|
52
|
+
because its navigation bar is translucent and the page scrolls underneath it.
|
|
53
|
+
4. Define the tab bar under `tabs:` in config/ruby_native.yml. Read config/routes.rb
|
|
54
|
+
first and use real paths. Pick two to five destinations somebody returns to, never an
|
|
55
|
+
action: an index, a search or browse page, a profile or settings page. Nothing that
|
|
56
|
+
404s or redirects for a signed-out user. Fewer than two tabs and the bar does not
|
|
57
|
+
appear at all, so if this app really has one destination, say so and leave `tabs:`
|
|
58
|
+
out. Icons are SF Symbols names on iOS and Material Symbols names on Android.
|
|
59
|
+
5. Add `native_form_tag` to the body of every page that submits a form: new, edit, sign
|
|
60
|
+
in, sign up, and anything else with a form on it. It marks the page so that going
|
|
61
|
+
back from the success page lands on what came before the form rather than on the
|
|
62
|
+
form again.
|
|
63
|
+
6. Fix the two auth problems that only exist in the native app. First, native users get
|
|
64
|
+
signed out at random unless the session cookie outlives the browser session: with the
|
|
65
|
+
Rails authentication generator that is
|
|
66
|
+
`cookies.signed.permanent[:session_id] = session.id`, and with Devise it is forcing
|
|
67
|
+
remember_me on for native users. Second, render `native_identity_tag current_user&.id`
|
|
68
|
+
on every page, signed out included, adapting `current_user` to whatever this app calls
|
|
69
|
+
it. The app keeps screens alive between page loads, so without that tag a sign-out
|
|
70
|
+
leaves the previous account's screens sitting in the other tabs. The value is hashed
|
|
71
|
+
with secret_key_base and exposes nothing. The install generator prints this line too,
|
|
72
|
+
so check the layout before adding a second one.
|
|
73
|
+
7. Set `appearance.background_color` in config/ruby_native.yml to the exact value of the
|
|
74
|
+
CSS body background, so screens stop flashing white between navigations.
|
|
75
|
+
|
|
76
|
+
Rules for the pass: the web app has to keep working exactly as it does today, so every
|
|
77
|
+
change is additive or scoped to native users. Do not add @hotwired/hotwire-native-bridge
|
|
78
|
+
or any Stimulus controllers for this; Ruby Native ships its own bridge. Do not change any
|
|
79
|
+
route or controller. Toasts, menus, haptics, badges, the floating action button, push
|
|
80
|
+
notifications and in-app purchases are documented and none of them belong in the first
|
|
81
|
+
pass.
|
|
82
|
+
|
|
83
|
+
When you are done, run `validate_config` and `check_views` and fix what they report. Then
|
|
84
|
+
tell the person to run `bundle exec ruby_native preview` and scan the code it prints with
|
|
85
|
+
the Ruby Native app on their phone. Do not run preview yourself: it opens a tunnel and
|
|
86
|
+
waits for a phone. List what you changed and what they should look at on the device,
|
|
87
|
+
since you cannot see the app running in the native shell.
|
|
88
|
+
|
|
89
|
+
## 3. The account and the app
|
|
90
|
+
|
|
91
|
+
If `status` says there is no login or no linked app, the person does two things, and you
|
|
92
|
+
wait after each one:
|
|
93
|
+
|
|
94
|
+
1. Create the app at #{Platform::HOST}/apps/new: a name and the site's URL, and an account
|
|
95
|
+
if they do not have one.
|
|
96
|
+
2. Run `bundle exec ruby_native login` on this machine. It opens the browser and they click
|
|
97
|
+
Approve. No password goes through the terminal.
|
|
98
|
+
|
|
99
|
+
Then call `link_app`.
|
|
100
|
+
|
|
101
|
+
## 4. The stores
|
|
102
|
+
|
|
103
|
+
Read `status`. Each track, iOS and Android, has a `next` act and a `blocked_by` list. If
|
|
104
|
+
neither track has started, ask which store first. Apple's enrollment is the longest wait,
|
|
105
|
+
so suggest starting it now and doing the rest while it runs.
|
|
106
|
+
|
|
107
|
+
The App Store Connect key is the one secret you handle, and only by its path: the person
|
|
108
|
+
mints it in App Store Connect and downloads the .p8; you pass the path, the key ID, the
|
|
109
|
+
issuer ID and the Team ID to `apple_key_set`, and never open the file.
|
|
110
|
+
|
|
111
|
+
Take the first blocker on the track they are working:
|
|
112
|
+
|
|
113
|
+
- `who: agent`: do it. The steps name the tool or the command. Deploys are
|
|
114
|
+
`bundle exec ruby_native deploy`, which builds every started store (`--ios` or `--android` for one); run it and follow the
|
|
115
|
+
build with `build_status`.
|
|
116
|
+
- `who: human`: show the person the `why`, the `url` and the `steps` exactly as given,
|
|
117
|
+
then stop. When they say it is done, read `status` again. A blocker with
|
|
118
|
+
`verifiable: false` is a questionnaire nobody can see: it clears only on the person's
|
|
119
|
+
word, on the dashboard or as a `confirmations` entry on `submit`, and only after they
|
|
120
|
+
have said, in their words, that it is done.
|
|
121
|
+
|
|
122
|
+
The listing is the person's words, so it gets a stop of its own. When a blocker asks for
|
|
123
|
+
it, write `config/ruby_native_listing.yml` and save it with `listing_push`, then stop: give
|
|
124
|
+
the person the blocker's `url`, where they read your draft and change
|
|
125
|
+
what they like. Run `bundle exec ruby_native listing publish` only after they say it reads
|
|
126
|
+
right.
|
|
127
|
+
|
|
128
|
+
When nothing blocks the act: `build` is a deploy. When `status` carries an `update`
|
|
129
|
+
with a `version`, pass it as `--version`, and do the `gem_step` first if there is one. `submit` is the `submit` tool, twice:
|
|
130
|
+
the first call returns the summary and a token; show the summary, get an explicit yes,
|
|
131
|
+
then call again with the token. `wait` means Apple has it and there is nothing to do.
|
|
132
|
+
`release` is the person's, on the Submit page at rubynative.com. `go_live` is the person's, in Play
|
|
133
|
+
Console, and `submit` with platform android prints the promotion steps.
|
|
134
|
+
|
|
135
|
+
## Rules
|
|
136
|
+
|
|
137
|
+
- One step at a time. Report after each.
|
|
138
|
+
- Money and secrets are the person's. Never enter card details, never paste a key or a
|
|
139
|
+
password. Hand over the URL.
|
|
140
|
+
- The web app keeps working exactly as it does today.
|
|
141
|
+
TEXT
|
|
142
|
+
|
|
143
|
+
LIST = [
|
|
144
|
+
{
|
|
145
|
+
name: "ship",
|
|
146
|
+
title: "Ship this app",
|
|
147
|
+
description: "Walk this Rails app to the App Store and Google Play with Ruby Native, one step at a " \
|
|
148
|
+
"time, stopping at the steps only a person can take.",
|
|
149
|
+
arguments: []
|
|
150
|
+
}
|
|
151
|
+
].freeze
|
|
152
|
+
|
|
153
|
+
def self.get(name)
|
|
154
|
+
return nil unless name == "ship"
|
|
155
|
+
|
|
156
|
+
{ description: LIST.first[:description], messages: [ { role: "user", content: { type: "text", text: SHIP } } ] }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|