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.
@@ -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